merge: dev into main
# Conflicts: # .github/workflows/reviewrouter-codex.yml # .github/workflows/reviewrouter-interaction.yml
This commit is contained in:
commit
c49d6c373e
198 changed files with 15694 additions and 1720 deletions
52
.dockerignore
Normal file
52
.dockerignore
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Dependencies installed inside the image
|
||||
node_modules/
|
||||
landing/node_modules/
|
||||
|
||||
# Local build output
|
||||
dist/
|
||||
dist-electron/
|
||||
dist-standalone/
|
||||
out/
|
||||
release/
|
||||
coverage/
|
||||
landing/.nuxt/
|
||||
landing/.output/
|
||||
electron.vite.config.*.mjs
|
||||
|
||||
# Runtime and local caches
|
||||
.git/
|
||||
.pnpm-store/
|
||||
.runtime-download/
|
||||
resources/runtime/*
|
||||
!resources/runtime/.gitkeep
|
||||
.eslintcache
|
||||
.eslintcache-fast
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local-only data
|
||||
.claude/
|
||||
.home/
|
||||
.serena/
|
||||
.playwright-mcp/
|
||||
logs/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# OS and editor noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Local scratch artifacts
|
||||
notification_example/
|
||||
temp/
|
||||
eslint-fix/
|
||||
remotion/*
|
||||
.tmp-*
|
||||
agent-teams-reference-fix-*.png
|
||||
ORCHESTRATOR_RELEASE_RUNBOOK.local.md
|
||||
4
.github/CONTRIBUTING.md
vendored
4
.github/CONTRIBUTING.md
vendored
|
|
@ -9,10 +9,12 @@ For big features and major changes, please discuss them in our [Discord](https:/
|
|||
Small fixes, bug reports, and minor improvements are always welcome - just open a PR.
|
||||
|
||||
## Prerequisites
|
||||
- Node.js 20+
|
||||
- Node.js 24.16.0 LTS
|
||||
- pnpm 10+
|
||||
- macOS, Windows, or Linux
|
||||
|
||||
On macOS, official Node.js 24 prebuilt binaries require macOS 13.5+ for source development.
|
||||
|
||||
## Setup
|
||||
```bash
|
||||
pnpm install
|
||||
|
|
|
|||
60
.github/ISSUE_TEMPLATE/bug_report.md
vendored
60
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -1,32 +1,60 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
about: Report a problem with the Agent Teams desktop app
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
**Summary**
|
||||
A clear description of what went wrong.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
**Area**
|
||||
Which part of the app is affected?
|
||||
- Agent teams / teammate launch
|
||||
- Team messaging / inboxes
|
||||
- Tasks / kanban board
|
||||
- Code review / diffs
|
||||
- Built-in editor / Git
|
||||
- Provider runtime (Claude, Codex, OpenCode)
|
||||
- Settings / authentication
|
||||
- Installer / updater
|
||||
- Other:
|
||||
|
||||
**Steps to reproduce**
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
2. Click on '...'
|
||||
3. Run / create / send '...'
|
||||
4. See the problem
|
||||
|
||||
**Frequency**
|
||||
How often does this happen? [Always / Often / Sometimes / Once]
|
||||
|
||||
**Regression**
|
||||
Did this work before? If yes, what was the last known good version or commit?
|
||||
|
||||
**Actual behavior**
|
||||
What happened instead?
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
What did you expect to happen?
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
**Environment**
|
||||
- OS and version: [e.g. macOS 15.5, Windows 11, Ubuntu 24.04]
|
||||
- App version or commit hash:
|
||||
- Install type: [GitHub release / source checkout / other]
|
||||
- Provider/runtime involved: [Claude / Codex / OpenCode / not sure / not relevant]
|
||||
- Desktop app mode: Electron
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
**Logs and diagnostics**
|
||||
If relevant, include redacted logs or diagnostics.
|
||||
- Do not paste API keys, access tokens, private repository contents, or other secrets.
|
||||
- For team launch hangs or missing teammate replies, check the newest artifact pack under `~/.claude/teams/<team>/launch-failure-artifacts/latest.json` and include the redacted `manifest.json` summary if you can.
|
||||
- For UI errors, include the Electron DevTools console error if one is shown.
|
||||
|
||||
**Screenshots or recording**
|
||||
If applicable, add screenshots or a short recording.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
Anything else that might help debug this.
|
||||
|
|
|
|||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and early ideas
|
||||
url: https://discord.gg/qtqSZSyuEc
|
||||
about: Use Discord for support questions, broad ideas, and discussions before opening a large feature request.
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/777genius/agent-teams-ai/security/policy
|
||||
about: Please report undisclosed security issues privately instead of opening a public issue.
|
||||
30
.github/ISSUE_TEMPLATE/feature_request.md
vendored
30
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
|
@ -1,20 +1,38 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
about: Suggest an improvement for the Agent Teams desktop app
|
||||
title: "[FEAT]"
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
**Summary**
|
||||
A clear description of the improvement you want.
|
||||
|
||||
**Problem**
|
||||
What workflow is difficult, slow, confusing, or missing today?
|
||||
|
||||
**Area**
|
||||
Which part of the app would this affect?
|
||||
- Agent teams / teammate launch
|
||||
- Team messaging / inboxes
|
||||
- Tasks / kanban board
|
||||
- Code review / diffs
|
||||
- Built-in editor / Git
|
||||
- Provider runtime (Claude, Codex, OpenCode)
|
||||
- Settings / authentication
|
||||
- Installer / updater
|
||||
- Other:
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
What should the app do?
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
What other approaches or workarounds did you try?
|
||||
|
||||
**Success criteria**
|
||||
How would you know this feature is working well?
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
Add screenshots, recordings, examples, or related issues if helpful.
|
||||
|
|
|
|||
1
.github/badges/version.svg
vendored
1
.github/badges/version.svg
vendored
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="103" height="20" role="img" aria-label="version: v2.1.2"><title>version: v2.1.2</title><g shape-rendering="crispEdges"><rect width="51" height="20" fill="#555"/><rect x="51" width="52" height="20" fill="#007ec6"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"><text x="25" y="14">version</text><text x="77" y="14">v2.1.2</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 445 B |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
|
|
@ -58,19 +58,22 @@ jobs:
|
|||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
|
||||
- name: Restore pnpm node-gyp executable bit
|
||||
run: |
|
||||
PNPM_STORE="$(pnpm store path)"
|
||||
find "$PNPM_STORE" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
PNPM_BIN_DIR="$(dirname "$(command -v pnpm)")"
|
||||
for path in "$PNPM_STORE" "${PNPM_HOME:-}" "$PNPM_BIN_DIR"; do
|
||||
if [ -d "$path" ]; then
|
||||
find "$path" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
|
@ -96,19 +99,22 @@ jobs:
|
|||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
|
||||
- name: Restore pnpm node-gyp executable bit
|
||||
run: |
|
||||
PNPM_STORE="$(pnpm store path)"
|
||||
find "$PNPM_STORE" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
PNPM_BIN_DIR="$(dirname "$(command -v pnpm)")"
|
||||
for path in "$PNPM_STORE" "${PNPM_HOME:-}" "$PNPM_BIN_DIR"; do
|
||||
if [ -d "$path" ]; then
|
||||
find "$path" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
|
@ -130,13 +136,11 @@ jobs:
|
|||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
|
|||
4
.github/workflows/codex-runtime-smoke.yml
vendored
4
.github/workflows/codex-runtime-smoke.yml
vendored
|
|
@ -52,13 +52,11 @@ jobs:
|
|||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
|
|
|
|||
2
.github/workflows/landing.yml
vendored
2
.github/workflows/landing.yml
vendored
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .node-version
|
||||
cache: npm
|
||||
cache-dependency-path: landing/package-lock.json
|
||||
|
||||
|
|
|
|||
47
.github/workflows/release.yml
vendored
47
.github/workflows/release.yml
vendored
|
|
@ -36,13 +36,11 @@ jobs:
|
|||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
|
||||
- name: Restore pnpm node-gyp executable bit
|
||||
|
|
@ -328,13 +326,11 @@ jobs:
|
|||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Python for node-gyp
|
||||
|
|
@ -449,13 +445,11 @@ jobs:
|
|||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Python for node-gyp
|
||||
|
|
@ -571,13 +565,11 @@ jobs:
|
|||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Python for node-gyp
|
||||
|
|
@ -864,37 +856,6 @@ jobs:
|
|||
TAG="${RELEASE_TAG}"
|
||||
gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false --latest
|
||||
|
||||
- name: Update README version badge
|
||||
if: ${{ inputs.publish_release }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEFAULT_BRANCH="$(gh repo view "${GITHUB_REPOSITORY}" --json defaultBranchRef --jq '.defaultBranchRef.name')"
|
||||
git fetch origin "${DEFAULT_BRANCH}"
|
||||
BADGE_WORKTREE="$(mktemp -d)"
|
||||
git worktree add --detach "${BADGE_WORKTREE}" "origin/${DEFAULT_BRANCH}"
|
||||
trap 'git worktree remove --force "${BADGE_WORKTREE}" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
BADGE_LABEL_WIDTH=51
|
||||
BADGE_VALUE="${RELEASE_TAG}"
|
||||
BADGE_VALUE_WIDTH=$(( ${#BADGE_VALUE} * 7 + 10 ))
|
||||
BADGE_WIDTH=$(( BADGE_LABEL_WIDTH + BADGE_VALUE_WIDTH ))
|
||||
BADGE_LABEL_X=$(( BADGE_LABEL_WIDTH / 2 ))
|
||||
BADGE_VALUE_X=$(( BADGE_LABEL_WIDTH + BADGE_VALUE_WIDTH / 2 ))
|
||||
mkdir -p "${BADGE_WORKTREE}/.github/badges"
|
||||
cat > "${BADGE_WORKTREE}/.github/badges/version.svg" <<EOF
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${BADGE_WIDTH}" height="20" role="img" aria-label="version: ${BADGE_VALUE}"><title>version: ${BADGE_VALUE}</title><g shape-rendering="crispEdges"><rect width="${BADGE_LABEL_WIDTH}" height="20" fill="#555"/><rect x="${BADGE_LABEL_WIDTH}" width="${BADGE_VALUE_WIDTH}" height="20" fill="#007ec6"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"><text x="${BADGE_LABEL_X}" y="14">version</text><text x="${BADGE_VALUE_X}" y="14">${BADGE_VALUE}</text></g></svg>
|
||||
EOF
|
||||
if git -C "${BADGE_WORKTREE}" diff --quiet -- .github/badges/version.svg; then
|
||||
exit 0
|
||||
fi
|
||||
git -C "${BADGE_WORKTREE}" config user.name "github-actions[bot]"
|
||||
git -C "${BADGE_WORKTREE}" config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git -C "${BADGE_WORKTREE}" add .github/badges/version.svg
|
||||
git -C "${BADGE_WORKTREE}" commit -m "docs(readme): update release badge to ${BADGE_VALUE}"
|
||||
git -C "${BADGE_WORKTREE}" push origin "HEAD:${DEFAULT_BRANCH}"
|
||||
|
||||
- name: Keep release as draft
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.publish_release }}
|
||||
run: echo "Draft release ${RELEASE_TAG} is ready. It was not published because publish_release=false."
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ jobs:
|
|||
REVIEW_ROUTER_REVIEW_WORKFLOW_FILE: "reviewrouter-codex.yml"
|
||||
steps:
|
||||
- name: Fetch ReviewRouter runtime config
|
||||
if: ${{ github.event_name != 'merge_group' && (github.event_name == 'workflow_dispatch' || github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
1
.node-version
Normal file
1
.node-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
24.16.0
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
24.16.0
|
||||
16
README.md
16
README.md
|
|
@ -18,7 +18,7 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/latest"><img src=".github/badges/version.svg" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/777genius/agent-teams-ai/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/agent-teams-ai?label=version&style=flat-square" alt="Latest Release" /></a>
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -150,6 +150,8 @@ An orchestration layer for AI agent teams across Claude, Codex, and OpenCode.
|
|||
|
||||
- **Zero-setup onboarding** — start with the free model with no auth, then connect paid/account providers only when you need them
|
||||
|
||||
- **Multi-language support** - choose the app language and preferred agent communication language. Current UI languages: Arabic, Bengali, Chinese, English, French, German, Hindi, Indonesian, Japanese, Korean, Portuguese, Russian, Spanish, Urdu.
|
||||
|
||||
- **Built-in code editor** — edit project files with Git support without leaving the app
|
||||
|
||||
- **Branch strategy** - choose per teammate at launch: use the main checkout or run selected agents in their own git worktree. You can still spell out branch rules in the provisioning prompt.
|
||||
|
|
@ -283,7 +285,9 @@ Electron 40, React 19, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.cl
|
|||
|
||||
<br />
|
||||
|
||||
**Prerequisites:** Node.js 20+, pnpm 10+
|
||||
**Prerequisites:** Node.js 24.16.0 LTS, pnpm 10+
|
||||
|
||||
On macOS, official Node.js 24 prebuilt binaries require macOS 13.5+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/777genius/agent-teams-ai.git
|
||||
|
|
@ -374,10 +378,18 @@ local packaging.
|
|||
|
||||
See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](.github/CODE_OF_CONDUCT.md).
|
||||
|
||||
## Partnerships
|
||||
|
||||
We are open to partnerships and collaboration opportunities. If you see a way to create value together, we are ready to discuss mutually beneficial terms.
|
||||
|
||||
Contact: [quantjumppro@gmail.com](mailto:quantjumppro@gmail.com)
|
||||
|
||||
## Security
|
||||
|
||||
IPC and standalone HTTP handlers validate IDs, paths, and payload shape at the boundary. Project editing and write operations are constrained to the selected project root, while read-only discovery also accesses local Claude data under `~/.claude/` and app-owned state paths when required. Path traversal and sensitive config/credential targets are blocked. See [SECURITY.md](.github/SECURITY.md) for details.
|
||||
|
||||
GitHub Dependabot monitors dependencies for known vulnerabilities, so security updates are surfaced quickly and applied in time.
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,6 @@
|
|||
"test:watch": "vitest --config vitest.config.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=24.16.0 <25"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,35 +8,55 @@
|
|||
# Run: docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro agent-teams-ai
|
||||
# =============================================================================
|
||||
|
||||
FROM node:20-slim AS builder
|
||||
ARG NODE_VERSION=24.16.0
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
# Native dependencies such as node-pty may need source builds on slim images.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies first (better layer caching)
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY patches ./patches
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN pnpm standalone:build
|
||||
RUN AGENT_TEAMS_DISABLE_SOURCEMAPS=1 pnpm standalone:build
|
||||
|
||||
# =============================================================================
|
||||
# Production stage — minimal image with only the built output
|
||||
# Production dependencies stage
|
||||
# =============================================================================
|
||||
FROM node:20-slim
|
||||
FROM base AS prod-deps
|
||||
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Copy package files and install production-only dependencies
|
||||
# Install production-only dependencies
|
||||
# (fastify, @fastify/cors, @fastify/static are externalized from the bundle)
|
||||
COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY patches ./patches
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts \
|
||||
&& pnpm rebuild node-pty cpu-features ssh2
|
||||
|
||||
# =============================================================================
|
||||
# Production stage - minimal image with only runtime dependencies and built output
|
||||
# =============================================================================
|
||||
FROM base
|
||||
|
||||
COPY --from=prod-deps /app/package.json /app/pnpm-lock.yaml ./
|
||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/agent-teams-controller ./agent-teams-controller
|
||||
|
||||
# Copy built standalone server and renderer output
|
||||
COPY --from=builder /app/dist-standalone ./dist-standalone
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { Plugin } from 'vite'
|
|||
// `vite build --config docker/vite.standalone.config.ts`, so __dirname
|
||||
// is docker/. All paths must resolve relative to the repo root.
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
const sourceMapsEnabled = process.env.AGENT_TEAMS_DISABLE_SOURCEMAPS !== '1'
|
||||
|
||||
// Node.js built-in modules that should be externalized
|
||||
const nodeBuiltins = new Set([
|
||||
|
|
@ -35,11 +36,13 @@ function nativeModuleStub(): Plugin {
|
|||
const STUB_ID = '\0native-stub'
|
||||
return {
|
||||
name: 'native-module-stub',
|
||||
enforce: 'pre',
|
||||
resolveId(source) {
|
||||
if (source.endsWith('.node')) return STUB_ID
|
||||
return null
|
||||
},
|
||||
load(id) {
|
||||
if (id.endsWith('.node')) return 'export default {}'
|
||||
if (id === STUB_ID) return 'export default {}'
|
||||
return null
|
||||
}
|
||||
|
|
@ -63,6 +66,8 @@ export const ipcMain = { handle: noop, on: noop, removeHandler: noop };
|
|||
export const shell = { openPath: noop, openExternal: noop };
|
||||
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
|
||||
export const Notification = class { show() {} };
|
||||
export const nativeImage = { createFromPath: () => proxyObj, createEmpty: () => proxyObj };
|
||||
export const net = { fetch: globalThis.fetch };
|
||||
export const safeStorage = { isEncryptionAvailable: () => false, encryptString: noop, decryptString: () => '' };
|
||||
export const screen = proxyObj;
|
||||
export default proxyObj;
|
||||
|
|
@ -87,6 +92,7 @@ export default defineConfig({
|
|||
plugins: [nativeModuleStub(), electronStub()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@features': resolve(ROOT, 'src/features'),
|
||||
'@main': resolve(ROOT, 'src/main'),
|
||||
'@shared': resolve(ROOT, 'src/shared'),
|
||||
'@preload': resolve(ROOT, 'src/preload')
|
||||
|
|
@ -99,7 +105,7 @@ export default defineConfig({
|
|||
},
|
||||
build: {
|
||||
outDir: 'dist-standalone',
|
||||
target: 'node20',
|
||||
target: 'node24',
|
||||
ssr: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
|
|
@ -119,6 +125,6 @@ export default defineConfig({
|
|||
}
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true
|
||||
sourcemap: sourceMapsEnabled
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1266,7 +1266,7 @@ Binary: null bytes в первых 8KB или расширение (.png, .wasm)
|
|||
|
||||
### 19.8 File Watcher (Impact: MEDIUM)
|
||||
|
||||
Проект использует `fs.watch({ recursive: true })`, не chokidar. Electron 40/Node 20+ OK.
|
||||
Проект использует `fs.watch({ recursive: true })`, не chokidar. Electron 40/Node 24.16+ OK.
|
||||
|
||||
**Решение:** fs.watch + фильтр (node_modules/.git/dist) + debounce 200ms + **opt-in** (ручной F5 по умолчанию) + cleanup.
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ const sentrySourceMapTargets = {
|
|||
},
|
||||
} as const
|
||||
|
||||
const sourceMapSetting = process.env.AGENT_TEAMS_DISABLE_SOURCEMAPS === '1' ? false : 'hidden'
|
||||
|
||||
// Sentry source map upload - only active in CI when SENTRY_AUTH_TOKEN is set.
|
||||
function createSentryPlugins(target: keyof typeof sentrySourceMapTargets): Plugin[] {
|
||||
if (!process.env.SENTRY_AUTH_TOKEN) return []
|
||||
|
|
@ -98,7 +100,7 @@ export default defineConfig({
|
|||
commonjsOptions: {
|
||||
strictRequires: [/node_modules\/.*ssh2\//],
|
||||
},
|
||||
sourcemap: 'hidden',
|
||||
sourcemap: sourceMapSetting,
|
||||
outDir: 'dist-electron/main',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
|
|
@ -169,7 +171,7 @@ export default defineConfig({
|
|||
},
|
||||
plugins: [react(), ...createSentryPlugins('renderer')],
|
||||
build: {
|
||||
sourcemap: 'hidden',
|
||||
sourcemap: sourceMapSetting,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html')
|
||||
|
|
|
|||
1
landing/.npmrc
Normal file
1
landing/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
|
|
@ -39,7 +39,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
const resolvedImage = computed<PageSeoImage>(() => {
|
||||
if (options.image) return options.image;
|
||||
return {
|
||||
url: "/og-image-agent-teams-v5.png",
|
||||
url: "/og-image-agent-teams-v6.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
type: "image/png",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const props = defineProps<{
|
|||
const { t } = useI18n();
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = ((config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai").replace(/\/+$/, "");
|
||||
const ogImage = `${siteUrl}/og-image-agent-teams-v5.png`;
|
||||
const ogImage = `${siteUrl}/og-image-agent-teams-v6.png`;
|
||||
|
||||
const statusCode = computed(() => props.error?.statusCode || 404);
|
||||
const isNotFound = computed(() => statusCode.value === 404);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
|
|||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const defaultSeoTitle = "Agent Teams - AI Agent Orchestration for Developers";
|
||||
const defaultSeoDescription = "Free, open-source desktop app for AI agent teams. Start with a free model with no auth, then connect Claude, Codex, or OpenCode when you need more models.";
|
||||
const defaultSeoImage = `${siteUrl.replace(/\/+$/, "")}/og-image-agent-teams-v5.png`;
|
||||
const defaultSeoImage = `${siteUrl.replace(/\/+$/, "")}/og-image-agent-teams-v6.png`;
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2026-01-19",
|
||||
|
|
|
|||
3
landing/package-lock.json
generated
3
landing/package-lock.json
generated
|
|
@ -35,6 +35,9 @@
|
|||
"vitepress": "2.0.0-alpha.17",
|
||||
"vitepress-codeblock-collapse": "^1.0.0",
|
||||
"vitepress-plugin-llms": "^1.12.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0 <25"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
"name": "agent-teams-landing",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.16.0 <25"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const publicBaseUrl =
|
|||
const docsUrl = `${publicBaseUrl}docs/`;
|
||||
const downloadUrl = `${publicBaseUrl}download/`;
|
||||
const ruDownloadUrl = `${publicBaseUrl}ru/download/`;
|
||||
const ogImageUrl = `${publicBaseUrl}og-image-agent-teams-v6.png`;
|
||||
const landingPublicDir = fileURLToPath(new URL("../../public", import.meta.url));
|
||||
|
||||
const rootGuide: DefaultTheme.SidebarItem[] = [
|
||||
|
|
@ -173,7 +174,7 @@ export default defineConfig({
|
|||
["meta", { property: "og:title", content: SITE_TITLE }],
|
||||
["meta", { property: "og:description", content: SITE_DESCRIPTION }],
|
||||
["meta", { property: "og:url", content: docsUrl }],
|
||||
["meta", { property: "og:image", content: `${publicBaseUrl}og-image.png` }],
|
||||
["meta", { property: "og:image", content: ogImageUrl }],
|
||||
["meta", { property: "og:image:width", content: "1200" }],
|
||||
["meta", { property: "og:image:height", content: "630" }],
|
||||
["meta", { property: "og:site_name", content: "Agent Teams" }],
|
||||
|
|
@ -181,7 +182,7 @@ export default defineConfig({
|
|||
["meta", { name: "twitter:card", content: "summary_large_image" }],
|
||||
["meta", { name: "twitter:title", content: SITE_TITLE }],
|
||||
["meta", { name: "twitter:description", content: SITE_DESCRIPTION }],
|
||||
["meta", { name: "twitter:image", content: `${publicBaseUrl}og-image.png` }],
|
||||
["meta", { name: "twitter:image", content: ogImageUrl }],
|
||||
[
|
||||
"script",
|
||||
{ type: "application/ld+json" },
|
||||
|
|
|
|||
|
|
@ -49,9 +49,11 @@ For source development, you also need:
|
|||
|
||||
| Tool | Version |
|
||||
| ------- | ------- |
|
||||
| Node.js | 20+ |
|
||||
| Node.js | 24.16.0 LTS |
|
||||
| pnpm | 10+ |
|
||||
|
||||
On macOS, official Node.js 24 prebuilt binaries require macOS 13.5+.
|
||||
|
||||
## Run from source
|
||||
|
||||
<InstallBlock command="git clone https://github.com/777genius/agent-teams-ai.git && cd agent-teams-ai && pnpm install && pnpm dev" />
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ For project conventions and architecture guidance, refer to these canonical file
|
|||
|
||||
**Or run from source** for development:
|
||||
|
||||
Requires Node.js 24.16.0 LTS and pnpm 10+. On macOS, official Node.js 24 prebuilt binaries require macOS 13.5+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/777genius/agent-teams-ai.git
|
||||
cd agent-teams-ai
|
||||
|
|
|
|||
|
|
@ -42,9 +42,11 @@ Gemini — поддерживаемый провайдер. Варианты aut
|
|||
|
||||
| Инструмент | Версия |
|
||||
| ---------- | ------ |
|
||||
| Node.js | 20+ |
|
||||
| Node.js | 24.16.0 LTS |
|
||||
| pnpm | 10+ |
|
||||
|
||||
На macOS официальные prebuilt-бинарники Node.js 24 требуют macOS 13.5+.
|
||||
|
||||
## Запуск из исходников
|
||||
|
||||
<InstallBlock command="git clone https://github.com/777genius/agent-teams-ai.git && cd agent-teams-ai && pnpm install && pnpm dev" label="Скопировать" copied-label="Скопировано" />
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ lang: ru-RU
|
|||
- **macOS, Windows или Linux** машина
|
||||
- **Git-репозиторий** в качестве проекта (рекомендуется для diff review и worktree isolation)
|
||||
- Бесплатная модель без авторизации для первого запуска или доступ к провайдеру, если нужны дополнительные модели: Anthropic (Claude), OpenAI (Codex), OpenRouter (OpenCode) или Google (Gemini)
|
||||
- Node.js 20+ и pnpm 10+ при запуске из исходников
|
||||
- Node.js 24.16.0 LTS и pnpm 10+ при запуске из исходников
|
||||
|
||||
Подробности и ссылки для скачивания — в разделе [Установка](/ru/guide/installation).
|
||||
|
||||
|
|
|
|||
BIN
landing/public/og-image-agent-teams-v6.png
Normal file
BIN
landing/public/og-image-agent-teams-v6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 673 KiB |
|
|
@ -15,8 +15,9 @@ export default defineEventHandler((event) => {
|
|||
const config = useRuntimeConfig();
|
||||
const siteUrl = ((config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai").replace(/\/+$/, "");
|
||||
const toSiteUrl = (path: string) => `${siteUrl}${path === "/" ? "/" : `/${path.replace(/^\/+/, "")}`}`;
|
||||
const homeImagePaths = ["og-image.png", ...screenshots.map((screenshot) => screenshot.path)];
|
||||
const downloadImagePaths = ["og-image.png", "logo-192.png"];
|
||||
const ogImagePath = "og-image-agent-teams-v6.png";
|
||||
const homeImagePaths = [ogImagePath, ...screenshots.map((screenshot) => screenshot.path)];
|
||||
const downloadImagePaths = [ogImagePath, "logo-192.png"];
|
||||
|
||||
setHeader(event, "content-type", "application/xml; charset=utf-8");
|
||||
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@
|
|||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.4",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.1.4",
|
||||
"@types/node": "^22.15.18"
|
||||
"vitest": "^3.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=24.16.0 <25"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { defineConfig } from 'tsup';
|
|||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node20',
|
||||
target: 'node24',
|
||||
platform: 'node',
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -16,6 +16,9 @@
|
|||
"bugs": {
|
||||
"url": "https://github.com/777genius/agent-teams-ai/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0 <25"
|
||||
},
|
||||
"main": "dist-electron/main/index.cjs",
|
||||
"scripts": {
|
||||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||
|
|
@ -83,10 +86,10 @@
|
|||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts",
|
||||
"standalone": "tsx src/main/standalone.ts",
|
||||
"standalone:build": "electron-vite build && vite build --config docker/vite.standalone.config.ts",
|
||||
"standalone:build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build && node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --config docker/vite.standalone.config.ts",
|
||||
"standalone:start": "node dist-standalone/index.cjs",
|
||||
"prepare": "husky",
|
||||
"postinstall": "electron-rebuild -f -o node-pty,ssh2,cpu-features || echo 'native Electron rebuild failed (terminal/ssh features may be degraded)'"
|
||||
"postinstall": "electron-rebuild -f -o node-pty,ssh2,cpu-features || echo 'native Electron rebuild failed (terminal/ssh features may be degraded)'; node ./scripts/ensure-electron-install.cjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{ts,tsx,js,jsx}": [
|
||||
|
|
@ -212,7 +215,7 @@
|
|||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^25.0.7",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/pidusage": "2.0.5",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
|
@ -388,7 +391,7 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
|
||||
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@hono/node-server@1": "1.19.13",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.16.0 <25"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
|
|
|
|||
544
pnpm-lock.yaml
544
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.47",
|
||||
"sourceRef": "v0.0.47",
|
||||
"version": "0.0.50",
|
||||
"sourceRef": "v0.0.50",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/agent-teams-ai",
|
||||
"releaseTag": "v2.1.2",
|
||||
"releaseTag": "v2.2.1",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.47.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.50.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.47.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.50.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.47.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.50.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.47.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.50.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,9 +98,9 @@ function postbuild() {
|
|||
}
|
||||
|
||||
if (missingDebugIdDirs.length > 0) {
|
||||
fail(
|
||||
console.warn(
|
||||
[
|
||||
'Sentry debug IDs were not injected into built JavaScript artifacts',
|
||||
'[sentry-release] warning: Sentry debug ID comments were not found in built JavaScript artifacts',
|
||||
...missingDebugIdDirs.map((dir) => ` - ${dir}`),
|
||||
].join('\n')
|
||||
);
|
||||
|
|
@ -117,7 +117,7 @@ function postbuild() {
|
|||
}
|
||||
|
||||
console.log(
|
||||
`[sentry-release] postbuild ok: ${jsFiles.length} JS artifacts built, debug IDs were injected, and source maps were removed after upload`
|
||||
`[sentry-release] postbuild ok: ${jsFiles.length} JS artifacts built and source maps were removed after upload`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { spawnWithWindowsShell } from './lib/windows-shell-spawn.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const standalonePort = process.env.STANDALONE_PORT?.trim() || '3456';
|
||||
|
|
@ -13,22 +14,11 @@ const corsOrigin =
|
|||
process.env.CORS_ORIGIN?.trim() ||
|
||||
`http://127.0.0.1:${webPort},http://localhost:${webPort}`;
|
||||
|
||||
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
|
||||
|
||||
function shouldUseWindowsShell(cmd) {
|
||||
if (process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return WINDOWS_SHELL_COMMANDS.has(path.basename(cmd).toLowerCase());
|
||||
}
|
||||
|
||||
function spawnProcess(cmd, args, env) {
|
||||
return spawn(cmd, args, {
|
||||
return spawnWithWindowsShell(cmd, args, {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: 'inherit',
|
||||
shell: shouldUseWindowsShell(cmd),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ import fs from 'node:fs';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import readline from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const uiRepoRoot = path.resolve(scriptDir, '..');
|
||||
const runtimeRepoRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim() ?? '';
|
||||
|
|
@ -22,26 +23,10 @@ const scriptArgs = process.argv.slice(2);
|
|||
const shouldPrintRuntimePath = scriptArgs.includes('--print-runtime-path');
|
||||
const electronViteArgs = scriptArgs.filter((arg) => arg !== '--print-runtime-path' && arg !== '--');
|
||||
const runtimeDisplayName = 'teams orchestrator';
|
||||
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
|
||||
|
||||
function shouldUseWindowsShell(cmd) {
|
||||
if (process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const extension = path.extname(cmd).toLowerCase();
|
||||
if (extension === '.cmd' || extension === '.bat') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const commandName = path.basename(cmd).toLowerCase();
|
||||
return WINDOWS_SHELL_COMMANDS.has(commandName);
|
||||
}
|
||||
|
||||
function runOrExit(cmd, args, options = {}) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
const result = spawnSyncWithWindowsShell(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
shell: shouldUseWindowsShell(cmd),
|
||||
...options,
|
||||
});
|
||||
|
||||
|
|
@ -56,9 +41,8 @@ function runOrExit(cmd, args, options = {}) {
|
|||
}
|
||||
|
||||
function runAndCapture(cmd, args, options = {}) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
const result = spawnSyncWithWindowsShell(cmd, args, {
|
||||
encoding: 'utf8',
|
||||
shell: shouldUseWindowsShell(cmd),
|
||||
...options,
|
||||
});
|
||||
|
||||
|
|
@ -539,6 +523,7 @@ async function main() {
|
|||
|
||||
const uiEnv = {
|
||||
...process.env,
|
||||
UV_THREADPOOL_SIZE: process.env.UV_THREADPOOL_SIZE?.trim() || '16',
|
||||
CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: resolvedRuntime.binaryPath,
|
||||
};
|
||||
delete uiEnv.CLAUDE_CLI_PATH;
|
||||
|
|
|
|||
67
scripts/ensure-electron-install.cjs
Normal file
67
scripts/ensure-electron-install.cjs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
const childProcess = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
function getPlatformPath() {
|
||||
const platform = process.env.npm_config_platform || os.platform();
|
||||
|
||||
switch (platform) {
|
||||
case 'mas':
|
||||
case 'darwin':
|
||||
return 'Electron.app/Contents/MacOS/Electron';
|
||||
case 'freebsd':
|
||||
case 'openbsd':
|
||||
case 'linux':
|
||||
return 'electron';
|
||||
case 'win32':
|
||||
return 'electron.exe';
|
||||
default:
|
||||
throw new Error(`Electron builds are not available on platform: ${platform}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getElectronPaths(electronDir, platformPath) {
|
||||
const pathFile = path.join(electronDir, 'path.txt');
|
||||
const distPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || path.join(electronDir, 'dist');
|
||||
const executablePath = path.join(distPath, platformPath);
|
||||
|
||||
return { executablePath, pathFile };
|
||||
}
|
||||
|
||||
function ensurePathFile(electronDir, platformPath) {
|
||||
const { pathFile } = getElectronPaths(electronDir, platformPath);
|
||||
|
||||
const currentPath = fs.existsSync(pathFile) ? fs.readFileSync(pathFile, 'utf8') : '';
|
||||
if (currentPath !== platformPath) {
|
||||
fs.writeFileSync(pathFile, platformPath);
|
||||
}
|
||||
}
|
||||
|
||||
function runElectronInstaller(installPath) {
|
||||
const result = childProcess.spawnSync(process.execPath, [installPath], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Electron installer failed with exit code ${result.status ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
const electronPackagePath = require.resolve('electron/package.json');
|
||||
const electronDir = path.dirname(electronPackagePath);
|
||||
const installPath = path.join(electronDir, 'install.js');
|
||||
const platformPath = getPlatformPath();
|
||||
const { executablePath, pathFile } = getElectronPaths(electronDir, platformPath);
|
||||
|
||||
if (!fs.existsSync(executablePath)) {
|
||||
runElectronInstaller(installPath);
|
||||
}
|
||||
|
||||
ensurePathFile(electronDir, platformPath);
|
||||
|
||||
if (!fs.existsSync(executablePath)) {
|
||||
console.warn(`Electron binary is missing after install: ${executablePath}`);
|
||||
console.warn(`Wrote Electron import marker: ${pathFile}`);
|
||||
}
|
||||
59
scripts/lib/windows-shell-spawn.mjs
Normal file
59
scripts/lib/windows-shell-spawn.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
|
||||
|
||||
export function quoteWindowsCmdArg(value) {
|
||||
const text = String(value);
|
||||
if (text.length === 0) {
|
||||
return '""';
|
||||
}
|
||||
if (!/[ \t\r\n"&|<>^()%!]/.test(text)) {
|
||||
return text;
|
||||
}
|
||||
return `"${text.replace(/%/g, '%%').replace(/(["^&|<>])/g, '^$1')}"`;
|
||||
}
|
||||
|
||||
export function shouldUseWindowsShell(command) {
|
||||
if (process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const extension = path.extname(command).toLowerCase();
|
||||
if (extension === '.cmd' || extension === '.bat') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return WINDOWS_SHELL_COMMANDS.has(path.basename(command).toLowerCase());
|
||||
}
|
||||
|
||||
function toWindowsShellCommand(command, args) {
|
||||
return [command, ...args].map(quoteWindowsCmdArg).join(' ');
|
||||
}
|
||||
|
||||
export function spawnWithWindowsShell(command, args, options = {}) {
|
||||
if (!shouldUseWindowsShell(command)) {
|
||||
return spawn(command, args, options);
|
||||
}
|
||||
|
||||
const safeOptions = { ...options };
|
||||
delete safeOptions.shell;
|
||||
return spawn(toWindowsShellCommand(command, args), {
|
||||
...safeOptions,
|
||||
shell: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function spawnSyncWithWindowsShell(command, args, options = {}) {
|
||||
if (!shouldUseWindowsShell(command)) {
|
||||
return spawnSync(command, args, options);
|
||||
}
|
||||
|
||||
const safeOptions = { ...options };
|
||||
delete safeOptions.shell;
|
||||
return spawnSync(toWindowsShellCommand(command, args), {
|
||||
...safeOptions,
|
||||
shell: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { resolveLiveSmokeOrchestratorCliPath } from './lib/live-smoke-runtime.mjs';
|
||||
import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
|
@ -26,7 +26,7 @@ if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
|
|||
console.log('Running agent CLI launch live smoke');
|
||||
console.log(`Claude runtime: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const result = spawnSync(
|
||||
const result = spawnSyncWithWindowsShell(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
|
|
@ -42,7 +42,6 @@ const result = spawnSync(
|
|||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -10,6 +9,7 @@ import {
|
|||
exitForSkippedPreflight,
|
||||
preflightOpenCodeLiveEnvironment,
|
||||
} from './lib/opencode-live-preflight.mjs';
|
||||
import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
|
@ -40,7 +40,7 @@ console.log(`Multi-lane: ${env.OPENCODE_E2E_MIXED_RECOVERY_MULTI === '1' ? 'enab
|
|||
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
|
||||
exitForSkippedPreflight(preflight);
|
||||
|
||||
const result = spawnSync(
|
||||
const result = spawnSyncWithWindowsShell(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
|
|
@ -56,7 +56,6 @@ const result = spawnSync(
|
|||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -10,6 +9,7 @@ import {
|
|||
exitForSkippedPreflight,
|
||||
preflightOpenCodeLiveEnvironment,
|
||||
} from './lib/opencode-live-preflight.mjs';
|
||||
import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
|
@ -46,7 +46,7 @@ console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`)
|
|||
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
|
||||
exitForSkippedPreflight(preflight);
|
||||
|
||||
const result = spawnSync(
|
||||
const result = spawnSyncWithWindowsShell(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
|
|
@ -62,7 +62,6 @@ const result = spawnSync(
|
|||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -10,6 +9,7 @@ import {
|
|||
exitForSkippedPreflight,
|
||||
preflightOpenCodeLiveEnvironment,
|
||||
} from './lib/opencode-live-preflight.mjs';
|
||||
import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
|
@ -38,7 +38,7 @@ console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`)
|
|||
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
|
||||
exitForSkippedPreflight(preflight);
|
||||
|
||||
const result = spawnSync(
|
||||
const result = spawnSyncWithWindowsShell(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
|
|
@ -54,7 +54,6 @@ const result = spawnSync(
|
|||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -10,6 +9,7 @@ import {
|
|||
exitForSkippedPreflight,
|
||||
preflightOpenCodeLiveEnvironment,
|
||||
} from './lib/opencode-live-preflight.mjs';
|
||||
import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
|
@ -36,7 +36,7 @@ console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`)
|
|||
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
|
||||
exitForSkippedPreflight(preflight);
|
||||
|
||||
const result = spawnSync(
|
||||
const result = spawnSyncWithWindowsShell(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
|
|
@ -52,7 +52,6 @@ const result = spawnSync(
|
|||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -10,6 +9,7 @@ import {
|
|||
exitForSkippedPreflight,
|
||||
preflightOpenCodeLiveEnvironment,
|
||||
} from './lib/opencode-live-preflight.mjs';
|
||||
import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
|
@ -38,7 +38,7 @@ console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`)
|
|||
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
|
||||
exitForSkippedPreflight(preflight);
|
||||
|
||||
const result = spawnSync(
|
||||
const result = spawnSyncWithWindowsShell(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
|
|
@ -54,7 +54,6 @@ const result = spawnSync(
|
|||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
|
|||
|
||||
import { resolveLiveSmokeOrchestratorCliPath } from './lib/live-smoke-runtime.mjs';
|
||||
import { preflightOpenCodeLiveEnvironment } from './lib/opencode-live-preflight.mjs';
|
||||
import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
|
@ -69,7 +70,7 @@ if (preflight.skipped.length > 0 && process.env.PROVIDER_LAUNCH_STRESS_STRICT ==
|
|||
env.PROVIDER_LAUNCH_STRESS_ORDER = preflight.order.join(',');
|
||||
console.log(`Runnable order: ${env.PROVIDER_LAUNCH_STRESS_ORDER}`);
|
||||
|
||||
const result = spawnSync(
|
||||
const result = spawnSyncWithWindowsShell(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
|
|
@ -85,7 +86,6 @@ const result = spawnSync(
|
|||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ import {
|
|||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import {
|
||||
hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext,
|
||||
isBootstrapConfirmedProvisionedButNotAliveFailure,
|
||||
} from '@shared/utils/teamLaunchFailureReason';
|
||||
import {
|
||||
isTeamTaskActivelyWorked,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
|
|
@ -560,6 +564,7 @@ export class TeamGraphAdapter {
|
|||
member.runtimeAdvisory,
|
||||
member.providerId,
|
||||
spawn,
|
||||
runtimeEntry,
|
||||
pendingApprovalAgents?.has(member.name) ?? false
|
||||
);
|
||||
const currentTask = member.currentTaskId
|
||||
|
|
@ -581,7 +586,11 @@ export class TeamGraphAdapter {
|
|||
spawnBootstrapStalled: spawn?.bootstrapStalled,
|
||||
spawnAgentToolAccepted: spawn?.agentToolAccepted,
|
||||
spawnHardFailure: spawn?.hardFailure,
|
||||
spawnHardFailureReason: spawn?.hardFailureReason,
|
||||
spawnError: spawn?.error,
|
||||
spawnRuntimeDiagnostic: spawn?.runtimeDiagnostic,
|
||||
spawnLivenessKind: spawn?.livenessKind,
|
||||
spawnRuntimeDiagnosticSeverity: spawn?.runtimeDiagnosticSeverity,
|
||||
spawnFirstSpawnAcceptedAt: spawn?.firstSpawnAcceptedAt,
|
||||
spawnUpdatedAt: spawn?.updatedAt,
|
||||
runtimeEntry,
|
||||
|
|
@ -599,7 +608,7 @@ export class TeamGraphAdapter {
|
|||
? 'terminated'
|
||||
: hasRunningTool
|
||||
? 'tool_calling'
|
||||
: TeamGraphAdapter.#mapMemberStatus(member.status, spawn),
|
||||
: TeamGraphAdapter.#mapMemberStatus(member.status, spawn, runtimeEntry),
|
||||
color: isTeamVisualOnline ? (member.color ?? undefined) : undefined,
|
||||
role: member.role ?? undefined,
|
||||
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
|
||||
|
|
@ -1269,9 +1278,15 @@ export class TeamGraphAdapter {
|
|||
runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'],
|
||||
providerId: ResolvedTeamMember['providerId'],
|
||||
spawn: MemberSpawnStatusEntry | undefined,
|
||||
runtimeEntry: TeamAgentRuntimeEntry | undefined,
|
||||
pendingApproval: boolean
|
||||
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {
|
||||
if (spawn?.launchState === 'failed_to_start' || spawn?.status === 'error') {
|
||||
const hasUnsuppressedSpawnFailure =
|
||||
TeamGraphAdapter.#hasUnsuppressedProvisionedButNotAliveFailure(spawn, runtimeEntry);
|
||||
if (
|
||||
hasUnsuppressedSpawnFailure &&
|
||||
(spawn?.launchState === 'failed_to_start' || spawn?.status === 'error')
|
||||
) {
|
||||
return { exceptionTone: 'error', exceptionLabel: 'spawn failed' };
|
||||
}
|
||||
if (pendingApproval || spawn?.launchState === 'runtime_pending_permission') {
|
||||
|
|
@ -1290,10 +1305,19 @@ export class TeamGraphAdapter {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
static #mapMemberStatus(status: string, spawn?: MemberSpawnStatusEntry): GraphNodeState {
|
||||
static #mapMemberStatus(
|
||||
status: string,
|
||||
spawn?: MemberSpawnStatusEntry,
|
||||
runtimeEntry?: TeamAgentRuntimeEntry
|
||||
): GraphNodeState {
|
||||
if (spawn?.launchState === 'runtime_pending_permission') return 'waiting';
|
||||
if (spawn?.status === 'spawning') return 'thinking';
|
||||
if (spawn?.status === 'error') return 'error';
|
||||
if (
|
||||
spawn?.status === 'error' &&
|
||||
TeamGraphAdapter.#hasUnsuppressedProvisionedButNotAliveFailure(spawn, runtimeEntry)
|
||||
) {
|
||||
return 'error';
|
||||
}
|
||||
if (spawn?.status === 'waiting') return 'waiting';
|
||||
switch (status) {
|
||||
case 'active':
|
||||
|
|
@ -1307,6 +1331,16 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
static #hasUnsuppressedProvisionedButNotAliveFailure(
|
||||
spawn: MemberSpawnStatusEntry | undefined,
|
||||
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||
): boolean {
|
||||
return (
|
||||
!isBootstrapConfirmedProvisionedButNotAliveFailure(spawn) ||
|
||||
hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext(spawn, runtimeEntry)
|
||||
);
|
||||
}
|
||||
|
||||
static #mapTaskStatus(status: string): GraphNodeState {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
|
|
|
|||
|
|
@ -373,7 +373,11 @@ const MemberPopoverContent = ({
|
|||
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
||||
spawnAgentToolAccepted: spawnEntry?.agentToolAccepted,
|
||||
spawnHardFailure: spawnEntry?.hardFailure,
|
||||
spawnHardFailureReason: spawnEntry?.hardFailureReason,
|
||||
spawnError: spawnEntry?.error,
|
||||
spawnRuntimeDiagnostic: spawnEntry?.runtimeDiagnostic,
|
||||
spawnLivenessKind: spawnEntry?.livenessKind,
|
||||
spawnRuntimeDiagnosticSeverity: spawnEntry?.runtimeDiagnosticSeverity,
|
||||
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
|
||||
spawnUpdatedAt: spawnEntry?.updatedAt,
|
||||
runtimeEntry,
|
||||
|
|
|
|||
|
|
@ -263,7 +263,8 @@ async function resolveCodexBinaryForAccountSnapshot(): Promise<string | null> {
|
|||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: CODEX_BINARY_COLD_RETRY_TIMEOUT_MS,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
background: true,
|
||||
source: 'codex-account-binary-discovery',
|
||||
});
|
||||
CodexBinaryResolver.clearCache();
|
||||
return CodexBinaryResolver.resolve();
|
||||
|
|
@ -293,6 +294,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
|
||||
private snapshotCache: CodexAccountSnapshotDto | null = null;
|
||||
private snapshotObservedAt = 0;
|
||||
private lastPublishedSnapshotUpdatedAtMs = 0;
|
||||
private refreshPromise: Promise<CodexAccountSnapshotDto> | null = null;
|
||||
private pendingRefreshOptions: CodexSnapshotRefreshOptions | null = null;
|
||||
private lastKnownAccount: CodexLastKnownAccount | null = null;
|
||||
|
|
@ -446,6 +448,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
this.lastKnownAccount = null;
|
||||
this.lastKnownRateLimits = null;
|
||||
this.lastKnownRuntimeContext = null;
|
||||
this.lastPublishedSnapshotUpdatedAtMs = 0;
|
||||
this.activeMutationCount = 0;
|
||||
if (this.mutationQueueRelease) {
|
||||
this.mutationQueueRelease();
|
||||
|
|
@ -519,7 +522,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
runtimeContext: freshRuntimeContext,
|
||||
login,
|
||||
rateLimits: this.snapshotCache?.rateLimits ?? null,
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
|
@ -539,7 +542,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
localActiveChatgptAccountPresent,
|
||||
login,
|
||||
rateLimits: null,
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
|
@ -699,20 +702,27 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
runtimeContext,
|
||||
login,
|
||||
rateLimits,
|
||||
updatedAt: new Date(now).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto {
|
||||
const publishedAtMs = Math.max(Date.now(), this.lastPublishedSnapshotUpdatedAtMs + 1);
|
||||
this.lastPublishedSnapshotUpdatedAtMs = publishedAtMs;
|
||||
const publishedSnapshot = {
|
||||
...nextSnapshot,
|
||||
updatedAt: new Date(publishedAtMs).toISOString(),
|
||||
};
|
||||
|
||||
if (this.disposed) {
|
||||
return deepClone(nextSnapshot);
|
||||
return deepClone(publishedSnapshot);
|
||||
}
|
||||
|
||||
this.snapshotCache = deepClone(nextSnapshot);
|
||||
this.snapshotCache = deepClone(publishedSnapshot);
|
||||
this.snapshotObservedAt = Date.now();
|
||||
const snapshot = deepClone(nextSnapshot);
|
||||
const snapshot = deepClone(publishedSnapshot);
|
||||
this.presenter.publish(snapshot);
|
||||
for (const listener of this.listeners) {
|
||||
listener(snapshot);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ function getRefreshIntervalMs(options: {
|
|||
: CODEX_VISIBLE_STANDARD_REFRESH_MS;
|
||||
}
|
||||
|
||||
function getSnapshotUpdatedAtMs(snapshot: CodexAccountSnapshotDto): number | null {
|
||||
const updatedAtMs = Date.parse(snapshot.updatedAt);
|
||||
return Number.isFinite(updatedAtMs) ? updatedAtMs : null;
|
||||
}
|
||||
|
||||
export function useCodexAccountSnapshot(options: {
|
||||
enabled: boolean;
|
||||
includeRateLimits?: boolean;
|
||||
|
|
@ -68,6 +73,7 @@ export function useCodexAccountSnapshot(options: {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(() => isDocumentVisible());
|
||||
const lastUpdatedAtRef = useRef<number | null>(null);
|
||||
const snapshotUpdatedAtRef = useRef<number | null>(null);
|
||||
const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0;
|
||||
const initialRefreshMaxDelayMs = options.initialRefreshMaxDelayMs;
|
||||
const [initialRefreshAttempted, setInitialRefreshAttempted] = useState(
|
||||
|
|
@ -75,6 +81,16 @@ export function useCodexAccountSnapshot(options: {
|
|||
);
|
||||
|
||||
const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
|
||||
const nextUpdatedAtMs = getSnapshotUpdatedAtMs(nextSnapshot);
|
||||
if (
|
||||
nextUpdatedAtMs !== null &&
|
||||
snapshotUpdatedAtRef.current !== null &&
|
||||
nextUpdatedAtMs < snapshotUpdatedAtRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
snapshotUpdatedAtRef.current = nextUpdatedAtMs ?? Date.now();
|
||||
lastUpdatedAtRef.current = Date.now();
|
||||
setSnapshot(nextSnapshot);
|
||||
setError(null);
|
||||
|
|
|
|||
|
|
@ -144,6 +144,21 @@ function mergeCodexNativeBackendOption(
|
|||
});
|
||||
}
|
||||
|
||||
function mergeCodexCapabilitiesWithSnapshot(
|
||||
provider: CliProviderStatus,
|
||||
snapshot: CodexAccountSnapshotDto
|
||||
): CliProviderStatus['capabilities'] {
|
||||
if (!snapshot.launchAllowed) {
|
||||
return provider.capabilities;
|
||||
}
|
||||
|
||||
return {
|
||||
...provider.capabilities,
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCodexProviderStatusWithSnapshot(
|
||||
provider: CliProviderStatus,
|
||||
snapshot: CodexAccountSnapshotDto | null
|
||||
|
|
@ -166,8 +181,10 @@ export function mergeCodexProviderStatusWithSnapshot(
|
|||
|
||||
return {
|
||||
...provider,
|
||||
supported: provider.supported || isCodexBootstrapPlaceholder(provider),
|
||||
supported:
|
||||
provider.supported || isCodexBootstrapPlaceholder(provider) || snapshot.launchAllowed,
|
||||
authenticated: snapshot.launchAllowed,
|
||||
capabilities: mergeCodexCapabilitiesWithSnapshot(provider, snapshot),
|
||||
authMethod:
|
||||
snapshot.effectiveAuthMode === 'chatgpt'
|
||||
? 'chatgpt'
|
||||
|
|
|
|||
|
|
@ -884,6 +884,7 @@
|
|||
},
|
||||
"status": {
|
||||
"checking": "Checking...",
|
||||
"modelsAvailable": "Models available",
|
||||
"checked": "Checked",
|
||||
"providerActivity": "Provider Activity",
|
||||
"notConnected": "Not connected",
|
||||
|
|
|
|||
|
|
@ -770,7 +770,7 @@
|
|||
"description": "Управляйте тем, как каждый провайдер подключается, и какой backend должен использовать multimodel runtime, если это поддерживается.",
|
||||
"fastMode": {
|
||||
"defaultOff": "По умолчанию выкл.",
|
||||
"description": "Включать Claude Code Fast mode по умолчанию для новых запусков Anthropic-команд, когда выбранные модель и runtime это поддерживают.",
|
||||
"description": "Включать Claude Code Fast mode по умолчанию для новых запусков Anthropic-команд, когда выбранные модель и runtime это поддерживают. Fast mode примерно в 2 раза дороже, но даёт скорость примерно в 1.5 раза выше.",
|
||||
"disabledHint": "Новые Anthropic-запуски остаются на обычной скорости, если команда явно не включает Fast mode.",
|
||||
"enabledHint": "Новые Anthropic-запуски будут запрашивать Fast mode по умолчанию, когда выбранная модель это поддерживает.",
|
||||
"notExposed": "Этот Anthropic runtime не предоставляет Fast mode.",
|
||||
|
|
@ -884,6 +884,7 @@
|
|||
},
|
||||
"status": {
|
||||
"checking": "Проверка...",
|
||||
"modelsAvailable": "Модели доступны",
|
||||
"checked": "Проверено",
|
||||
"providerActivity": "Активность провайдеров",
|
||||
"notConnected": "Не подключено",
|
||||
|
|
|
|||
|
|
@ -2759,6 +2759,7 @@ export default interface Resources {
|
|||
chatGptVerificationDegraded: 'ChatGPT account detected - account verification is currently degraded.';
|
||||
checked: 'Checked';
|
||||
checking: 'Checking...';
|
||||
modelsAvailable: 'Models available';
|
||||
codexLocalAccountNeedsReconnect: 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.';
|
||||
codexNativeReady: 'Codex native ready';
|
||||
codexNativeUnavailable: 'Codex native unavailable';
|
||||
|
|
|
|||
|
|
@ -273,6 +273,37 @@ Reply to this comment using MCP tool task_add_comment.
|
|||
expect(result.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips structured non-human user-role messages for inbound text extraction', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'teammate-protocol',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
protocolKind: 'teammate-message',
|
||||
origin: { kind: 'teammate' },
|
||||
isSynthetic: true,
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: '<teammate-message teammate_id="alice">Looks good</teammate-message>',
|
||||
}),
|
||||
message({
|
||||
uuid: 'coordinator',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
origin: { kind: 'coordinator' },
|
||||
isSynthetic: true,
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: 'Human: I tested the feature looks good',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts tool_use input and tool_result output without rendering huge payloads', () => {
|
||||
const hugeOutput = 'x'.repeat(10_000);
|
||||
const result = extractMemberLogPreviewItems({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { isHumanAuthoredUserTurn, type MessageOriginLike } from '@shared/utils/userTurnProvenance';
|
||||
|
||||
import type {
|
||||
MemberLogPreviewItem,
|
||||
MemberLogPreviewItemKind,
|
||||
|
|
@ -20,6 +22,10 @@ export interface MemberLogPreviewParsedMessage {
|
|||
timestamp: Date | string;
|
||||
content: string | MemberLogPreviewContentBlock[];
|
||||
isMeta?: boolean;
|
||||
isSynthetic?: boolean;
|
||||
isReplay?: boolean;
|
||||
origin?: MessageOriginLike;
|
||||
protocolKind?: string;
|
||||
toolCalls?: readonly {
|
||||
id?: string;
|
||||
name?: string;
|
||||
|
|
@ -2065,13 +2071,6 @@ function resolveMessageRole(message: MemberLogPreviewParsedMessage): string {
|
|||
return message.role ?? message.type ?? '';
|
||||
}
|
||||
|
||||
function messageHasToolResult(message: MemberLogPreviewParsedMessage): boolean {
|
||||
if ((message.toolResults?.length ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
return Array.isArray(message.content) && message.content.some(isToolResultBlock);
|
||||
}
|
||||
|
||||
function buildItemId(input: {
|
||||
provider: MemberLogStreamProvider;
|
||||
sourceId: string;
|
||||
|
|
@ -2419,7 +2418,7 @@ export function extractMemberLogPreviewItems(
|
|||
}
|
||||
}
|
||||
|
||||
if (role === 'user' && message.isMeta !== true && !messageHasToolResult(message)) {
|
||||
if (role === 'user' && isHumanAuthoredUserTurn(message)) {
|
||||
const inboundPreview = extractInboundTextPreview(message.content, textLimit);
|
||||
if (inboundPreview) {
|
||||
candidates.push(
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useAppTranslation } from '@features/localization/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
|
||||
import { useMemberLogStream } from '../hooks/useMemberLogStream';
|
||||
import { ExecutionLogStreamView } from '../ui/ExecutionLogStreamView';
|
||||
import { MemberRuntimeProcessLogsPanel } from '../ui/MemberRuntimeProcessLogsPanel';
|
||||
|
||||
import type { MemberLogStreamSegment, MemberRuntimeLogKind } from '../../contracts';
|
||||
import type { MemberLogStreamSegment } from '../../contracts';
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
interface MemberLogStreamSectionProps {
|
||||
|
|
@ -19,10 +17,6 @@ interface MemberLogStreamSectionProps {
|
|||
onInitialLoadErrorChange?: (hasError: boolean) => void;
|
||||
}
|
||||
|
||||
function describeMemberStream(): string {
|
||||
return 'Member-scoped transcript and runtime logs rendered with the same execution-log components used in Task Log Stream.';
|
||||
}
|
||||
|
||||
function getSegmentMetaLabel(segment: MemberLogStreamSegment): string {
|
||||
const details = [segment.source.label];
|
||||
if (segment.source.laneId) {
|
||||
|
|
@ -45,17 +39,8 @@ export const MemberLogStreamSection = ({
|
|||
onInitialLoadErrorChange,
|
||||
}: Readonly<MemberLogStreamSectionProps>): React.JSX.Element => {
|
||||
const { t } = useAppTranslation('team');
|
||||
const [selectedLogView, setSelectedLogView] = useState<'execution' | 'process'>('execution');
|
||||
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
|
||||
const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled });
|
||||
const loadRuntimeLogTail = useCallback(
|
||||
(input: {
|
||||
readonly kind: MemberRuntimeLogKind;
|
||||
readonly maxBytes: number;
|
||||
readonly forceRefresh?: boolean;
|
||||
}) => api.memberLogStream.getMemberRuntimeLogTail(teamName, member.name, input),
|
||||
[member.name, teamName]
|
||||
);
|
||||
const hasInitialLoadError = Boolean(error && !stream && !loading);
|
||||
const boundedHistoryNote = useMemo(() => {
|
||||
if (!stream) return null;
|
||||
|
|
@ -71,56 +56,24 @@ export const MemberLogStreamSection = ({
|
|||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex rounded-md bg-[var(--color-surface-subtle)] p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
selectedLogView === 'execution'
|
||||
? 'bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
|
||||
}`}
|
||||
onClick={() => setSelectedLogView('execution')}
|
||||
>
|
||||
{t('memberLogStream.tabs.execution')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
selectedLogView === 'process'
|
||||
? 'bg-[var(--color-surface)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
|
||||
}`}
|
||||
onClick={() => setSelectedLogView('process')}
|
||||
>
|
||||
{t('memberLogStream.tabs.process')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedLogView === 'execution' ? (
|
||||
<ExecutionLogStreamView
|
||||
title={t('memberLogStream.logs.title')}
|
||||
description={describeMemberStream()}
|
||||
stream={stream}
|
||||
loading={loading}
|
||||
error={error}
|
||||
teamName={teamName}
|
||||
teamMembers={teamMembers}
|
||||
loadingText={t('memberLogStream.logs.loading')}
|
||||
emptyTitle={t('memberLogStream.logs.emptyTitle')}
|
||||
emptyDescription={t('memberLogStream.logs.emptyDescription')}
|
||||
selectionResetKey={`${teamName}:${member.name}`}
|
||||
boundedHistoryNote={boundedHistoryNote}
|
||||
forceSegmentHeaders
|
||||
showSegmentParticipantBadge={false}
|
||||
buildSegmentRenderKey={buildMemberSegmentRenderKey}
|
||||
getSegmentMetaLabel={getSegmentMetaLabel}
|
||||
/>
|
||||
) : (
|
||||
<MemberRuntimeProcessLogsPanel
|
||||
enabled={enabled && selectedLogView === 'process'}
|
||||
loadRuntimeLogTail={loadRuntimeLogTail}
|
||||
/>
|
||||
)}
|
||||
<ExecutionLogStreamView
|
||||
title={t('memberLogStream.logs.title')}
|
||||
stream={stream}
|
||||
loading={loading}
|
||||
error={error}
|
||||
teamName={teamName}
|
||||
teamMembers={teamMembers}
|
||||
loadingText={t('memberLogStream.logs.loading')}
|
||||
emptyTitle={t('memberLogStream.logs.emptyTitle')}
|
||||
emptyDescription={t('memberLogStream.logs.emptyDescription')}
|
||||
selectionResetKey={`${teamName}:${member.name}`}
|
||||
boundedHistoryNote={boundedHistoryNote}
|
||||
forceSegmentHeaders
|
||||
showIntro={false}
|
||||
showSegmentParticipantBadge={false}
|
||||
buildSegmentRenderKey={buildMemberSegmentRenderKey}
|
||||
getSegmentMetaLabel={getSegmentMetaLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ interface ParticipantVisual {
|
|||
|
||||
export interface ExecutionLogStreamViewProps<TStream extends ExecutionLogStreamLike> {
|
||||
title: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
stream: TStream | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
|
@ -312,7 +312,9 @@ export const ExecutionLogStreamView = <TStream extends ExecutionLogStreamLike>({
|
|||
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">
|
||||
{title}
|
||||
</h4>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
|
||||
{description ? (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{boundedHistoryNote ? (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import {
|
|||
} from '@features/recent-projects/contracts';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import {
|
||||
estimateDashboardRecentProjectsPayloadBytes,
|
||||
getRecentProjectsMemoryDiagnostics,
|
||||
} from '../recentProjectsDiagnostics';
|
||||
|
||||
import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
|
|
@ -15,13 +20,22 @@ export function registerRecentProjectsHttp(
|
|||
feature: RecentProjectsFeatureFacade
|
||||
): void {
|
||||
app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise<DashboardRecentProjectsPayload> => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return (
|
||||
normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? {
|
||||
projects: [],
|
||||
degraded: true,
|
||||
}
|
||||
);
|
||||
const payload = normalizeDashboardRecentProjectsPayload(
|
||||
await feature.listDashboardRecentProjects()
|
||||
) ?? {
|
||||
projects: [],
|
||||
degraded: true,
|
||||
};
|
||||
logger.info('dashboard recent-projects HTTP loaded', {
|
||||
count: payload.projects.length,
|
||||
degraded: payload.degraded,
|
||||
durationMs: Date.now() - startedAt,
|
||||
estimatedPayloadBytes: estimateDashboardRecentProjectsPayloadBytes(payload),
|
||||
...getRecentProjectsMemoryDiagnostics(),
|
||||
});
|
||||
return payload;
|
||||
} catch (error) {
|
||||
logger.error('Failed to load dashboard recent projects via HTTP', error);
|
||||
return { projects: [], degraded: true };
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import {
|
|||
} from '@features/recent-projects/contracts';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import {
|
||||
estimateDashboardRecentProjectsPayloadBytes,
|
||||
getRecentProjectsMemoryDiagnostics,
|
||||
} from '../recentProjectsDiagnostics';
|
||||
|
||||
import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature';
|
||||
import type { IpcMain } from 'electron';
|
||||
|
||||
|
|
@ -14,13 +19,22 @@ export function registerRecentProjectsIpc(
|
|||
feature: RecentProjectsFeatureFacade
|
||||
): void {
|
||||
ipcMain.handle(GET_DASHBOARD_RECENT_PROJECTS, async () => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return (
|
||||
normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? {
|
||||
projects: [],
|
||||
degraded: true,
|
||||
}
|
||||
);
|
||||
const payload = normalizeDashboardRecentProjectsPayload(
|
||||
await feature.listDashboardRecentProjects()
|
||||
) ?? {
|
||||
projects: [],
|
||||
degraded: true,
|
||||
};
|
||||
logger.info('dashboard recent-projects IPC loaded', {
|
||||
count: payload.projects.length,
|
||||
degraded: payload.degraded,
|
||||
durationMs: Date.now() - startedAt,
|
||||
estimatedPayloadBytes: estimateDashboardRecentProjectsPayloadBytes(payload),
|
||||
...getRecentProjectsMemoryDiagnostics(),
|
||||
});
|
||||
return payload;
|
||||
} catch (error) {
|
||||
logger.error('Failed to load dashboard recent projects via IPC', error);
|
||||
return { projects: [], degraded: true };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import type {
|
||||
DashboardRecentProject,
|
||||
DashboardRecentProjectsPayload,
|
||||
} from '@features/recent-projects/contracts';
|
||||
|
||||
function stringBytes(value: string | undefined): number {
|
||||
return value ? Buffer.byteLength(value, 'utf8') : 0;
|
||||
}
|
||||
|
||||
function estimateOpenTargetBytes(openTarget: DashboardRecentProject['openTarget']): number {
|
||||
if (openTarget.type === 'existing-worktree') {
|
||||
return stringBytes(openTarget.repositoryId) + stringBytes(openTarget.worktreeId) + 48;
|
||||
}
|
||||
|
||||
return stringBytes(openTarget.path) + 32;
|
||||
}
|
||||
|
||||
export function estimateDashboardRecentProjectsPayloadBytes(
|
||||
payload: DashboardRecentProjectsPayload
|
||||
): number {
|
||||
let bytes = 32;
|
||||
for (const project of payload.projects) {
|
||||
bytes +=
|
||||
160 +
|
||||
stringBytes(project.id) +
|
||||
stringBytes(project.name) +
|
||||
stringBytes(project.primaryPath) +
|
||||
stringBytes(project.primaryBranch) +
|
||||
estimateOpenTargetBytes(project.openTarget);
|
||||
for (const associatedPath of project.associatedPaths) {
|
||||
bytes += stringBytes(associatedPath) + 8;
|
||||
}
|
||||
for (const providerId of project.providerIds) {
|
||||
bytes += stringBytes(providerId) + 8;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function getRecentProjectsMemoryDiagnostics(): {
|
||||
rssBytes: number;
|
||||
heapUsedBytes: number;
|
||||
heapTotalBytes: number;
|
||||
} {
|
||||
const memory = process.memoryUsage();
|
||||
return {
|
||||
rssBytes: memory.rss,
|
||||
heapUsedBytes: memory.heapUsed,
|
||||
heapTotalBytes: memory.heapTotal,
|
||||
};
|
||||
}
|
||||
|
|
@ -23,12 +23,14 @@ const CODEX_SESSION_FILE_SOFT_BUDGET_MS = 6_500;
|
|||
const CODEX_SESSION_FILE_MAX_UNCACHED_READS_PER_RUN = 160;
|
||||
const CODEX_SESSION_FILE_READ_BATCH_SIZE = 24;
|
||||
const CODEX_SESSION_FILE_READ_TIMEOUT_MS = 700;
|
||||
const CODEX_SESSION_FILE_DISCOVERY_STAT_BATCH_SIZE = 64;
|
||||
const CODEX_SESSION_METADATA_READ_LIMIT_BYTES = 128 * 1024;
|
||||
const CODEX_SESSION_FILE_CACHE_SCHEMA_VERSION = 1;
|
||||
const CODEX_SESSION_FILE_CACHE_RELATIVE_PATH = path.join(
|
||||
'recent-projects',
|
||||
'codex-session-files-index.json'
|
||||
);
|
||||
const CODEX_SESSION_FILE_CACHE_MAX_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
interface CodexSessionFileEntry {
|
||||
filePath: string;
|
||||
|
|
@ -80,6 +82,11 @@ interface CodexSessionSnapshotLoadResult {
|
|||
degraded: boolean;
|
||||
stats: {
|
||||
files: number;
|
||||
visitedFiles: number;
|
||||
droppedOlderFiles: number;
|
||||
statFailures: number;
|
||||
directoriesVisited: number;
|
||||
discoveryTimedOut: boolean;
|
||||
cached: number;
|
||||
uncachedReads: number;
|
||||
timedOutReads: number;
|
||||
|
|
@ -88,6 +95,19 @@ interface CodexSessionSnapshotLoadResult {
|
|||
};
|
||||
}
|
||||
|
||||
interface CodexSessionFileListingResult {
|
||||
files: CodexSessionFileEntry[];
|
||||
visitedFiles: number;
|
||||
statFailures: number;
|
||||
directoriesVisited: number;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
interface InFlightListRequest {
|
||||
contextKey: string;
|
||||
promise: Promise<RecentProjectsSourceResult>;
|
||||
}
|
||||
|
||||
function emptyCache(): CodexSessionFileCacheFile {
|
||||
return {
|
||||
schemaVersion: CODEX_SESSION_FILE_CACHE_SCHEMA_VERSION,
|
||||
|
|
@ -95,6 +115,21 @@ function emptyCache(): CodexSessionFileCacheFile {
|
|||
};
|
||||
}
|
||||
|
||||
function captureMemoryDiagnostics(): {
|
||||
rssBytes: number;
|
||||
heapUsedBytes: number;
|
||||
heapTotalBytes: number;
|
||||
externalBytes: number;
|
||||
} {
|
||||
const memory = process.memoryUsage();
|
||||
return {
|
||||
rssBytes: memory.rss,
|
||||
heapUsedBytes: memory.heapUsed,
|
||||
heapTotalBytes: memory.heapTotal,
|
||||
externalBytes: memory.external,
|
||||
};
|
||||
}
|
||||
|
||||
function isUsableCacheEntry(
|
||||
entry: CodexSessionFileCacheEntry | undefined,
|
||||
file: CodexSessionFileEntry
|
||||
|
|
@ -211,45 +246,167 @@ async function readFirstLineWithTimeout(
|
|||
return result;
|
||||
}
|
||||
|
||||
async function listJsonlFiles(root: string, maxDepth: number): Promise<CodexSessionFileEntry[]> {
|
||||
async function walk(directory: string, depth: number): Promise<CodexSessionFileEntry[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry): Promise<CodexSessionFileEntry[]> => {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return depth < maxDepth ? walk(entryPath, depth + 1) : [];
|
||||
}
|
||||
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(entryPath);
|
||||
return [
|
||||
{
|
||||
filePath: entryPath,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return files.flat();
|
||||
function insertRecentSessionFile(
|
||||
files: CodexSessionFileEntry[],
|
||||
file: CodexSessionFileEntry,
|
||||
limit: number
|
||||
): void {
|
||||
if (limit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return walk(root, 0);
|
||||
if (files.length >= limit && file.mtimeMs <= files[files.length - 1].mtimeMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = files.length;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (file.mtimeMs > files[mid].mtimeMs) {
|
||||
high = mid;
|
||||
} else {
|
||||
low = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
files.splice(low, 0, file);
|
||||
if (files.length > limit) {
|
||||
files.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function selectMostRecentSessionFiles(
|
||||
files: CodexSessionFileEntry[],
|
||||
limit: number
|
||||
): CodexSessionFileEntry[] {
|
||||
const selected: CodexSessionFileEntry[] = [];
|
||||
for (const file of files) {
|
||||
insertRecentSessionFile(selected, file, limit);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function listRecentJsonlFiles(
|
||||
root: string,
|
||||
maxDepth: number,
|
||||
limit: number,
|
||||
deadlineMs: number
|
||||
): Promise<CodexSessionFileListingResult> {
|
||||
const selectedFiles: CodexSessionFileEntry[] = [];
|
||||
let visitedFiles = 0;
|
||||
let statFailures = 0;
|
||||
let directoriesVisited = 0;
|
||||
let timedOut = false;
|
||||
|
||||
const hasBudget = (): boolean => {
|
||||
if (Date.now() < deadlineMs) {
|
||||
return true;
|
||||
}
|
||||
timedOut = true;
|
||||
return false;
|
||||
};
|
||||
|
||||
async function statJsonlFile(filePath: string): Promise<CodexSessionFileEntry | null> {
|
||||
if (!hasBudget()) {
|
||||
return null;
|
||||
}
|
||||
visitedFiles += 1;
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
return {
|
||||
filePath,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
};
|
||||
} catch {
|
||||
statFailures += 1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectFileStats(filePaths: string[]): Promise<void> {
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < filePaths.length && hasBudget();
|
||||
offset += CODEX_SESSION_FILE_DISCOVERY_STAT_BATCH_SIZE
|
||||
) {
|
||||
const batch = filePaths.slice(offset, offset + CODEX_SESSION_FILE_DISCOVERY_STAT_BATCH_SIZE);
|
||||
const stats = await Promise.all(batch.map((filePath) => statJsonlFile(filePath)));
|
||||
for (const file of stats) {
|
||||
if (file) {
|
||||
insertRecentSessionFile(selectedFiles, file, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function walk(directory: string, depth: number): Promise<void> {
|
||||
if (!hasBudget()) {
|
||||
return;
|
||||
}
|
||||
let directoryHandle;
|
||||
try {
|
||||
directoryHandle = await fs.opendir(directory, { encoding: 'utf8' });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
directoriesVisited += 1;
|
||||
const fileBatch: string[] = [];
|
||||
const childDirectories: string[] = [];
|
||||
const flushFileBatch = async (): Promise<void> => {
|
||||
if (!fileBatch.length) {
|
||||
return;
|
||||
}
|
||||
const batch = fileBatch.splice(0, fileBatch.length);
|
||||
await collectFileStats(batch);
|
||||
};
|
||||
|
||||
try {
|
||||
for await (const entry of directoryHandle) {
|
||||
if (!hasBudget()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (depth < maxDepth) {
|
||||
childDirectories.push(entryPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
||||
fileBatch.push(entryPath);
|
||||
if (fileBatch.length >= CODEX_SESSION_FILE_DISCOVERY_STAT_BATCH_SIZE) {
|
||||
await flushFileBatch();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await flushFileBatch();
|
||||
|
||||
for (const childDirectory of childDirectories) {
|
||||
if (!hasBudget()) {
|
||||
return;
|
||||
}
|
||||
await walk(childDirectory, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root, 0);
|
||||
|
||||
return {
|
||||
files: selectedFiles,
|
||||
visitedFiles,
|
||||
statFailures,
|
||||
directoriesVisited,
|
||||
timedOut,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionSnapshot(
|
||||
|
|
@ -294,6 +451,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
readonly timeoutMs = CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS;
|
||||
readonly #codexHome: string;
|
||||
readonly #cachePath: string;
|
||||
#inFlightList: InFlightListRequest | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
|
|
@ -323,6 +481,21 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
};
|
||||
}
|
||||
|
||||
const contextKey = `${activeContext.type}:${activeContext.id}`;
|
||||
if (this.#inFlightList?.contextKey === contextKey) {
|
||||
return this.#inFlightList.promise;
|
||||
}
|
||||
|
||||
const request = this.#listLocal(activeContext).finally(() => {
|
||||
if (this.#inFlightList?.promise === request) {
|
||||
this.#inFlightList = null;
|
||||
}
|
||||
});
|
||||
this.#inFlightList = { contextKey, promise: request };
|
||||
return request;
|
||||
}
|
||||
|
||||
async #listLocal(activeContext: ServiceContext): Promise<RecentProjectsSourceResult> {
|
||||
try {
|
||||
const snapshotResult = await this.#listRecentSessionSnapshots();
|
||||
const candidates = await Promise.all(
|
||||
|
|
@ -339,6 +512,7 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
count: validCandidates.length,
|
||||
codexHome: this.#codexHome,
|
||||
degraded: snapshotResult.degraded,
|
||||
...captureMemoryDiagnostics(),
|
||||
...snapshotResult.stats,
|
||||
});
|
||||
|
||||
|
|
@ -361,16 +535,34 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
async #listRecentSessionSnapshots(): Promise<CodexSessionSnapshotLoadResult> {
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + CODEX_SESSION_FILE_SOFT_BUDGET_MS;
|
||||
const files = [
|
||||
...(await listJsonlFiles(path.join(this.#codexHome, 'sessions'), 4)),
|
||||
...(await listJsonlFiles(path.join(this.#codexHome, 'archived_sessions'), 1)),
|
||||
].sort((left, right) => right.mtimeMs - left.mtimeMs);
|
||||
const sessionFiles = await listRecentJsonlFiles(
|
||||
path.join(this.#codexHome, 'sessions'),
|
||||
4,
|
||||
CODEX_SESSION_FILE_PARSE_LIMIT,
|
||||
deadline
|
||||
);
|
||||
const archivedSessionFiles = await listRecentJsonlFiles(
|
||||
path.join(this.#codexHome, 'archived_sessions'),
|
||||
1,
|
||||
CODEX_SESSION_FILE_PARSE_LIMIT,
|
||||
deadline
|
||||
);
|
||||
const files = selectMostRecentSessionFiles(
|
||||
[...sessionFiles.files, ...archivedSessionFiles.files],
|
||||
CODEX_SESSION_FILE_PARSE_LIMIT
|
||||
);
|
||||
const visitedFiles = sessionFiles.visitedFiles + archivedSessionFiles.visitedFiles;
|
||||
const statFailures = sessionFiles.statFailures + archivedSessionFiles.statFailures;
|
||||
const directoriesVisited =
|
||||
sessionFiles.directoriesVisited + archivedSessionFiles.directoriesVisited;
|
||||
const droppedOlderFiles = Math.max(0, visitedFiles - statFailures - files.length);
|
||||
const discoveryTimedOut = sessionFiles.timedOut || archivedSessionFiles.timedOut;
|
||||
|
||||
const snapshotsByCwd = new Map<string, CodexSessionProjectSnapshot>();
|
||||
const candidateFiles = files.slice(0, CODEX_SESSION_FILE_PARSE_LIMIT);
|
||||
const candidateFiles = files;
|
||||
const cache = await this.#readCacheSafe();
|
||||
const nextCacheEntries = new Map<string, CodexSessionFileCacheEntry>();
|
||||
let degraded = false;
|
||||
let degraded = discoveryTimedOut;
|
||||
let cached = 0;
|
||||
let uncachedReads = 0;
|
||||
let timedOutReads = 0;
|
||||
|
|
@ -454,6 +646,12 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
if (degraded) {
|
||||
this.deps.logger.warn('codex session-file recent-projects source partial', {
|
||||
files: candidateFiles.length,
|
||||
visitedFiles,
|
||||
droppedOlderFiles,
|
||||
statFailures,
|
||||
directoriesVisited,
|
||||
discoveryTimedOut,
|
||||
...captureMemoryDiagnostics(),
|
||||
cached,
|
||||
uncachedReads,
|
||||
timedOutReads,
|
||||
|
|
@ -468,6 +666,11 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
degraded,
|
||||
stats: {
|
||||
files: candidateFiles.length,
|
||||
visitedFiles,
|
||||
droppedOlderFiles,
|
||||
statFailures,
|
||||
directoriesVisited,
|
||||
discoveryTimedOut,
|
||||
cached,
|
||||
uncachedReads,
|
||||
timedOutReads,
|
||||
|
|
@ -479,6 +682,16 @@ export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjec
|
|||
|
||||
async #readCacheSafe(): Promise<CodexSessionFileCacheFile> {
|
||||
try {
|
||||
const stats = await fs.stat(this.#cachePath);
|
||||
if (stats.size > CODEX_SESSION_FILE_CACHE_MAX_BYTES) {
|
||||
this.deps.logger.warn('codex session-file recent-projects cache skipped - too large', {
|
||||
cachePath: this.#cachePath,
|
||||
bytes: stats.size,
|
||||
maxBytes: CODEX_SESSION_FILE_CACHE_MAX_BYTES,
|
||||
});
|
||||
return emptyCache();
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(this.#cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<CodexSessionFileCacheFile>;
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ export function createRecentProjectsFeature(deps: {
|
|||
return {
|
||||
listDashboardRecentProjects: async () => {
|
||||
const activeContext = deps.getActiveContext();
|
||||
const payload = await useCase.execute(`dashboard-recent-projects:${activeContext.id}`);
|
||||
const payload = await useCase.execute(
|
||||
`dashboard-recent-projects:${activeContext.type}:${activeContext.id}`
|
||||
);
|
||||
return normalizeDashboardRecentProjectsPayload(payload) ?? { projects: [], degraded: true };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import { type DashboardRecentProject } from '@features/recent-projects/contracts
|
|||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
captureContextScopedRequestEpoch,
|
||||
isContextScopedRequestEpochCurrent,
|
||||
} from '@renderer/store/utils/contextScopedRequestEpoch';
|
||||
import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -21,7 +25,6 @@ import {
|
|||
import { useOpenRecentProject } from './useOpenRecentProject';
|
||||
|
||||
import type { RecentProjectCardModel } from '../adapters/RecentProjectsSectionAdapter';
|
||||
import type { TeamSummary } from '@shared/types';
|
||||
|
||||
const INITIAL_RECENT_PROJECTS = 11;
|
||||
const LOAD_MORE_STEP = 8;
|
||||
|
|
@ -70,6 +73,7 @@ export function useRecentProjectsSection(
|
|||
globalTasksLoading,
|
||||
fetchAllTasks,
|
||||
teams,
|
||||
activeContextId,
|
||||
provisioningRuns,
|
||||
currentProvisioningRunIdByTeam,
|
||||
provisioningSnapshotByTeam,
|
||||
|
|
@ -80,12 +84,16 @@ export function useRecentProjectsSection(
|
|||
globalTasksLoading: state.globalTasksLoading,
|
||||
fetchAllTasks: state.fetchAllTasks,
|
||||
teams: state.teams,
|
||||
activeContextId: state.activeContextId,
|
||||
provisioningRuns: state.provisioningRuns,
|
||||
currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam,
|
||||
provisioningSnapshotByTeam: state.provisioningSnapshotByTeam,
|
||||
}))
|
||||
);
|
||||
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
|
||||
const initialSnapshot = useMemo(
|
||||
() => getRecentProjectsClientSnapshot(activeContextId),
|
||||
[activeContextId]
|
||||
);
|
||||
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
||||
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
|
||||
initialSnapshot?.payload.projects ?? []
|
||||
|
|
@ -105,6 +113,8 @@ export function useRecentProjectsSection(
|
|||
const recentProjectsRef = useRef<DashboardRecentProject[]>(
|
||||
initialSnapshot?.payload.projects ?? []
|
||||
);
|
||||
const activeContextIdRef = useRef(activeContextId);
|
||||
activeContextIdRef.current = activeContextId;
|
||||
const provisioningState = useMemo(
|
||||
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
|
||||
[currentProvisioningRunIdByTeam, provisioningRuns]
|
||||
|
|
@ -125,37 +135,73 @@ export function useRecentProjectsSection(
|
|||
recentProjectsRef.current = recentProjects;
|
||||
}, [recentProjects]);
|
||||
|
||||
const reload = useCallback(async (options?: { force?: boolean }): Promise<void> => {
|
||||
const hasVisibleProjects =
|
||||
recentProjectsRef.current.length > 0 || getRecentProjectsClientSnapshot() != null;
|
||||
const reload = useCallback(
|
||||
async (options?: { force?: boolean }): Promise<void> => {
|
||||
const requestContextId = activeContextId;
|
||||
const requestContextEpoch = captureContextScopedRequestEpoch();
|
||||
const hasVisibleProjects =
|
||||
recentProjectsRef.current.length > 0 ||
|
||||
getRecentProjectsClientSnapshot(requestContextId) != null;
|
||||
|
||||
if (!hasVisibleProjects) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await loadRecentProjectsWithClientCache(
|
||||
() => api.getDashboardRecentProjects(),
|
||||
options
|
||||
);
|
||||
setRecentProjects(payload.projects);
|
||||
setRecentProjectsDegraded(payload.degraded);
|
||||
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0));
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
if (!hasVisibleProjects) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await loadRecentProjectsWithClientCache(
|
||||
requestContextId,
|
||||
() => api.getDashboardRecentProjects(),
|
||||
options
|
||||
);
|
||||
if (
|
||||
activeContextIdRef.current !== requestContextId ||
|
||||
!isContextScopedRequestEpochCurrent(requestContextEpoch)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setRecentProjects(payload.projects);
|
||||
setRecentProjectsDegraded(payload.degraded);
|
||||
setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0));
|
||||
} catch (nextError) {
|
||||
if (
|
||||
activeContextIdRef.current !== requestContextId ||
|
||||
!isContextScopedRequestEpochCurrent(requestContextEpoch)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects');
|
||||
} finally {
|
||||
if (
|
||||
activeContextIdRef.current === requestContextId &&
|
||||
isContextScopedRequestEpochCurrent(requestContextEpoch)
|
||||
) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeContextId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const snapshot = getRecentProjectsClientSnapshot();
|
||||
const snapshot = getRecentProjectsClientSnapshot(activeContextId);
|
||||
if (snapshot) {
|
||||
setRecentProjects(snapshot.payload.projects);
|
||||
setRecentProjectsDegraded(snapshot.payload.degraded);
|
||||
setDegradedRefreshCount(snapshot.payload.degraded ? 1 : 0);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setRecentProjects([]);
|
||||
setRecentProjectsDegraded(false);
|
||||
setDegradedRefreshCount(0);
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
if (snapshot && !snapshot.isStale) {
|
||||
return;
|
||||
}
|
||||
|
||||
void reload({ force: snapshot != null });
|
||||
}, [reload]);
|
||||
}, [activeContextId, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recentProjectsDegraded) {
|
||||
|
|
@ -188,11 +234,17 @@ export function useRecentProjectsSection(
|
|||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const requestContextId = activeContextId;
|
||||
const requestContextEpoch = captureContextScopedRequestEpoch();
|
||||
|
||||
void api.teams
|
||||
.aliveList()
|
||||
.then((teamNames) => {
|
||||
if (!cancelled) {
|
||||
if (
|
||||
!cancelled &&
|
||||
activeContextIdRef.current === requestContextId &&
|
||||
isContextScopedRequestEpochCurrent(requestContextEpoch)
|
||||
) {
|
||||
setAliveTeams(teamNames);
|
||||
}
|
||||
})
|
||||
|
|
@ -201,7 +253,7 @@ export function useRecentProjectsSection(
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [provisioningTeamNamesKey, teams]);
|
||||
}, [activeContextId, provisioningTeamNamesKey, teams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
|
|
@ -225,22 +277,21 @@ export function useRecentProjectsSection(
|
|||
});
|
||||
}, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]);
|
||||
|
||||
const decoratedCards = useMemo(
|
||||
() =>
|
||||
adaptRecentProjectsSection({
|
||||
projects: sortRecentProjectsByDisplayPriority(recentProjects),
|
||||
taskCountsByProject,
|
||||
activeTeamsByProject,
|
||||
tasksLoading: globalTasksLoading,
|
||||
}),
|
||||
[
|
||||
activeTeamsByProject,
|
||||
globalTasksLoading,
|
||||
openHistoryVersion,
|
||||
recentProjects,
|
||||
const decoratedCards = useMemo(() => {
|
||||
void openHistoryVersion;
|
||||
return adaptRecentProjectsSection({
|
||||
projects: sortRecentProjectsByDisplayPriority(recentProjects),
|
||||
taskCountsByProject,
|
||||
]
|
||||
);
|
||||
activeTeamsByProject,
|
||||
tasksLoading: globalTasksLoading,
|
||||
});
|
||||
}, [
|
||||
activeTeamsByProject,
|
||||
globalTasksLoading,
|
||||
openHistoryVersion,
|
||||
recentProjects,
|
||||
taskCountsByProject,
|
||||
]);
|
||||
|
||||
const filteredCards = useMemo(
|
||||
() => decoratedCards.filter((card) => matchesSearch(card.project, searchQuery)),
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
|
|||
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 30_000;
|
||||
|
||||
let cachedPayload: DashboardRecentProjectsPayloadLike = null;
|
||||
let cachedKey: string | null = null;
|
||||
let cachedAt = 0;
|
||||
let inFlightLoad: Promise<DashboardRecentProjectsPayload> | null = null;
|
||||
let inFlightLoad: { key: string; promise: Promise<DashboardRecentProjectsPayload> } | null = null;
|
||||
|
||||
export interface RecentProjectsClientSnapshot {
|
||||
payload: DashboardRecentProjectsPayload;
|
||||
|
|
@ -18,7 +19,13 @@ export interface RecentProjectsClientSnapshot {
|
|||
isStale: boolean;
|
||||
}
|
||||
|
||||
export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null {
|
||||
export function getRecentProjectsClientSnapshot(
|
||||
cacheKey: string
|
||||
): RecentProjectsClientSnapshot | null {
|
||||
if (cachedKey !== cacheKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPayload = normalizeDashboardRecentProjectsPayload(cachedPayload);
|
||||
if (!normalizedPayload) {
|
||||
return null;
|
||||
|
|
@ -40,39 +47,44 @@ export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot
|
|||
}
|
||||
|
||||
export async function loadRecentProjectsWithClientCache(
|
||||
cacheKey: string,
|
||||
loader: () => Promise<DashboardRecentProjectsPayloadLike>,
|
||||
options?: { force?: boolean }
|
||||
): Promise<DashboardRecentProjectsPayload> {
|
||||
const force = options?.force ?? false;
|
||||
const snapshot = getRecentProjectsClientSnapshot();
|
||||
const snapshot = getRecentProjectsClientSnapshot(cacheKey);
|
||||
|
||||
if (!force && snapshot && !snapshot.isStale) {
|
||||
return snapshot.payload;
|
||||
}
|
||||
|
||||
if (inFlightLoad) {
|
||||
return inFlightLoad;
|
||||
if (inFlightLoad?.key === cacheKey) {
|
||||
return inFlightLoad.promise;
|
||||
}
|
||||
|
||||
const request = loader()
|
||||
.then((payloadLike) => {
|
||||
const normalizedPayload = normalizeDashboardRecentProjectsPayload(payloadLike);
|
||||
cachedPayload = normalizedPayload;
|
||||
cachedAt = Date.now();
|
||||
if (inFlightLoad?.key === cacheKey && inFlightLoad.promise === request) {
|
||||
cachedKey = normalizedPayload ? cacheKey : null;
|
||||
cachedPayload = normalizedPayload;
|
||||
cachedAt = Date.now();
|
||||
}
|
||||
return normalizedPayload ?? { projects: [], degraded: true };
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightLoad === request) {
|
||||
if (inFlightLoad?.promise === request) {
|
||||
inFlightLoad = null;
|
||||
}
|
||||
});
|
||||
|
||||
inFlightLoad = request;
|
||||
inFlightLoad = { key: cacheKey, promise: request };
|
||||
return request;
|
||||
}
|
||||
|
||||
export function __resetRecentProjectsClientCacheForTests(): void {
|
||||
cachedPayload = null;
|
||||
cachedKey = null;
|
||||
cachedAt = 0;
|
||||
inFlightLoad = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2287,8 +2287,7 @@ export function RuntimeProviderManagementPanelView({
|
|||
: t('runtimeProvider.providers.countFallback');
|
||||
const launchableModelCount = state.view?.configuredModels?.length ?? 0;
|
||||
const modelsLoading = state.loading && launchableModelCount === 0;
|
||||
const activeSection =
|
||||
selectedSection ?? (modelsLoading || launchableModelCount > 0 ? 'models' : 'providers');
|
||||
const activeSection = selectedSection ?? 'providers';
|
||||
const hasProjectContext = Boolean(projectPath?.trim());
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -258,6 +258,213 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
expect(snapshot.teamLaunchState).toBe('partial_failure');
|
||||
});
|
||||
|
||||
it('heals bootstrap-confirmed provisioned-but-not-alive primary status while building snapshots', () => {
|
||||
const snapshot = buildMixedPersistedLaunchSnapshot({
|
||||
teamName: 'signal-ops',
|
||||
launchPhase: 'finished',
|
||||
updatedAt: '2026-05-25T20:14:02.147Z',
|
||||
leadDefaults: {
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: null,
|
||||
selectedFastMode: 'off',
|
||||
resolvedFastMode: false,
|
||||
launchIdentity: null,
|
||||
},
|
||||
primaryMembers: [{ name: 'tom', providerId: 'anthropic', model: 'sonnet', effort: 'medium' }],
|
||||
primaryStatuses: {
|
||||
tom: {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'CLI process exited (code 1) \u2014 team provisioned but not alive',
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
runtimeDiagnostic:
|
||||
'runtime pid could not be verified because process table is unavailable',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
firstSpawnAcceptedAt: '2026-05-25T20:13:46.326Z',
|
||||
lastHeartbeatAt: '2026-05-25T20:13:56.110Z',
|
||||
updatedAt: '2026-05-25T20:14:02.147Z',
|
||||
} as never,
|
||||
},
|
||||
secondaryMembers: [],
|
||||
});
|
||||
|
||||
expect(snapshot.members.tom).toMatchObject({
|
||||
launchState: 'confirmed_alive',
|
||||
bootstrapConfirmed: true,
|
||||
runtimeAlive: true,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
});
|
||||
expect(snapshot.summary).toMatchObject({
|
||||
confirmedCount: 1,
|
||||
failedCount: 0,
|
||||
pendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('clean_success');
|
||||
});
|
||||
|
||||
it('heals Windows process-table-unavailable provisioned-but-not-alive primary metadata', () => {
|
||||
const snapshot = buildMixedPersistedLaunchSnapshot({
|
||||
teamName: 'signal-ops',
|
||||
launchPhase: 'finished',
|
||||
updatedAt: '2026-05-25T20:14:02.147Z',
|
||||
leadDefaults: {
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: null,
|
||||
selectedFastMode: 'off',
|
||||
resolvedFastMode: false,
|
||||
launchIdentity: null,
|
||||
},
|
||||
primaryMembers: [{ name: 'tom', providerId: 'anthropic', model: 'sonnet', effort: 'medium' }],
|
||||
primaryStatuses: {
|
||||
tom: {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: true,
|
||||
hardFailureReason:
|
||||
'CLI process exited (code 1) - team provisioned but not alive; process table unavailable',
|
||||
livenessKind: 'stale_metadata',
|
||||
runtimeDiagnostic:
|
||||
'runtime pid could not be verified because process table is unavailable',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
firstSpawnAcceptedAt: '2026-05-25T20:13:46.326Z',
|
||||
lastHeartbeatAt: '2026-05-25T20:13:56.110Z',
|
||||
updatedAt: '2026-05-25T20:14:02.147Z',
|
||||
} as never,
|
||||
},
|
||||
secondaryMembers: [],
|
||||
});
|
||||
|
||||
expect(snapshot.members.tom).toMatchObject({
|
||||
launchState: 'confirmed_alive',
|
||||
bootstrapConfirmed: true,
|
||||
runtimeAlive: true,
|
||||
hardFailure: false,
|
||||
});
|
||||
expect(snapshot.summary).toMatchObject({
|
||||
confirmedCount: 1,
|
||||
failedCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps bootstrap-confirmed provisioned-but-not-alive primary status failed when diagnostics are errors', () => {
|
||||
const snapshot = buildMixedPersistedLaunchSnapshot({
|
||||
teamName: 'signal-ops',
|
||||
launchPhase: 'finished',
|
||||
updatedAt: '2026-05-25T20:14:02.147Z',
|
||||
leadDefaults: {
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: null,
|
||||
selectedFastMode: 'off',
|
||||
resolvedFastMode: false,
|
||||
launchIdentity: null,
|
||||
},
|
||||
primaryMembers: [{ name: 'tom', providerId: 'anthropic', model: 'sonnet', effort: 'medium' }],
|
||||
primaryStatuses: {
|
||||
tom: {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'CLI process exited (code 1) - team provisioned but not alive',
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
runtimeDiagnostic: 'Runtime process crashed',
|
||||
runtimeDiagnosticSeverity: 'error',
|
||||
updatedAt: '2026-05-25T20:14:02.147Z',
|
||||
} as never,
|
||||
},
|
||||
secondaryMembers: [],
|
||||
});
|
||||
|
||||
expect(snapshot.members.tom).toMatchObject({
|
||||
launchState: 'failed_to_start',
|
||||
bootstrapConfirmed: true,
|
||||
runtimeAlive: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'CLI process exited (code 1) - team provisioned but not alive',
|
||||
runtimeDiagnostic: 'Runtime process crashed',
|
||||
runtimeDiagnosticSeverity: 'error',
|
||||
});
|
||||
expect(snapshot.summary).toMatchObject({
|
||||
confirmedCount: 0,
|
||||
failedCount: 1,
|
||||
pendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('partial_failure');
|
||||
});
|
||||
|
||||
it('keeps bootstrap-confirmed provisioned-but-not-alive secondary status failed when liveness is stopped', () => {
|
||||
const snapshot = buildMixedPersistedLaunchSnapshot({
|
||||
teamName: 'mixed-team',
|
||||
launchPhase: 'finished',
|
||||
updatedAt: '2026-05-25T20:14:02.147Z',
|
||||
leadDefaults: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
selectedFastMode: 'off',
|
||||
resolvedFastMode: false,
|
||||
launchIdentity: null,
|
||||
},
|
||||
primaryMembers: [],
|
||||
primaryStatuses: {},
|
||||
secondaryMembers: [
|
||||
{
|
||||
laneId: 'secondary:opencode:tom',
|
||||
member: {
|
||||
name: 'tom',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax-m2.5-free',
|
||||
effort: 'medium',
|
||||
},
|
||||
leadDefaults: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
selectedFastMode: 'off',
|
||||
resolvedFastMode: false,
|
||||
launchIdentity: null,
|
||||
},
|
||||
evidence: {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'CLI process exited (code 1) - team provisioned but not alive',
|
||||
livenessKind: 'not_found',
|
||||
runtimeDiagnostic: 'Runtime is no longer registered',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
} as never,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.members.tom).toMatchObject({
|
||||
laneKind: 'secondary',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
hardFailure: true,
|
||||
livenessKind: 'not_found',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
});
|
||||
expect(snapshot.summary).toMatchObject({
|
||||
confirmedCount: 0,
|
||||
failedCount: 1,
|
||||
pendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('partial_failure');
|
||||
});
|
||||
|
||||
it('preserves permission-blocked side-lane members as runtime_pending_permission', () => {
|
||||
const snapshot = buildMixedPersistedLaunchSnapshot({
|
||||
teamName: 'mixed-team',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import {
|
||||
hasUnsafeProvisionedButNotAliveRuntimeEvidence,
|
||||
isBootstrapConfirmedProvisionedButNotAliveFailure,
|
||||
} from '@shared/utils/teamLaunchFailureReason';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type {
|
||||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatusEntry,
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
OpenCodeBootstrapEvidenceSource,
|
||||
|
|
@ -95,6 +98,20 @@ function preservesStrongRuntimeAlive(value: {
|
|||
);
|
||||
}
|
||||
|
||||
function canHealBootstrapConfirmedProvisionedButNotAliveFailure(
|
||||
entry:
|
||||
| (Parameters<typeof isBootstrapConfirmedProvisionedButNotAliveFailure>[0] & {
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
})
|
||||
| undefined
|
||||
): boolean {
|
||||
return (
|
||||
isBootstrapConfirmedProvisionedButNotAliveFailure(entry) &&
|
||||
!hasUnsafeProvisionedButNotAliveRuntimeEvidence(entry)
|
||||
);
|
||||
}
|
||||
|
||||
function hasMaterializedOpenCodeRuntimeMarker(value: {
|
||||
runtimeAlive?: boolean;
|
||||
runtimePid?: number;
|
||||
|
|
@ -233,16 +250,22 @@ function createPrimaryLaneMemberState(params: {
|
|||
const runtime = params.status;
|
||||
const strongRuntimeAlive = preservesStrongRuntimeAlive(runtime ?? {});
|
||||
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
|
||||
const launchState =
|
||||
runtime?.launchState ??
|
||||
deriveMemberLaunchState({
|
||||
hardFailure: runtime?.hardFailure,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
agentToolAccepted: runtime?.agentToolAccepted,
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
|
||||
});
|
||||
const hardFailure = runtime?.hardFailure === true || launchState === 'failed_to_start';
|
||||
const healBootstrapConfirmedProvisionedButNotAlive =
|
||||
canHealBootstrapConfirmedProvisionedButNotAliveFailure(runtime);
|
||||
const runtimeAlive = healBootstrapConfirmedProvisionedButNotAlive || strongRuntimeAlive;
|
||||
const launchState = healBootstrapConfirmedProvisionedButNotAlive
|
||||
? 'confirmed_alive'
|
||||
: (runtime?.launchState ??
|
||||
deriveMemberLaunchState({
|
||||
hardFailure: runtime?.hardFailure,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
agentToolAccepted: runtime?.agentToolAccepted,
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
|
||||
}));
|
||||
const hardFailure =
|
||||
!healBootstrapConfirmedProvisionedButNotAlive &&
|
||||
(runtime?.hardFailure === true || launchState === 'failed_to_start');
|
||||
const base: PersistedTeamLaunchMemberState = {
|
||||
name: params.member.name.trim(),
|
||||
providerId,
|
||||
|
|
@ -272,7 +295,7 @@ function createPrimaryLaneMemberState(params: {
|
|||
: undefined,
|
||||
launchState,
|
||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||
hardFailure,
|
||||
hardFailureReason: hardFailure ? (runtime?.hardFailureReason ?? runtime?.error) : undefined,
|
||||
|
|
@ -285,7 +308,7 @@ function createPrimaryLaneMemberState(params: {
|
|||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
|
||||
lastRuntimeAliveAt: preservesStrongRuntimeAlive(runtime ?? {}) ? params.updatedAt : undefined,
|
||||
lastRuntimeAliveAt: runtimeAlive ? params.updatedAt : undefined,
|
||||
lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt,
|
||||
sources,
|
||||
diagnostics: undefined,
|
||||
|
|
@ -301,16 +324,22 @@ function createSecondaryLaneMemberState(
|
|||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||
const evidence = params.evidence;
|
||||
const strongRuntimeAlive = preservesStrongRuntimeAlive(evidence ?? {});
|
||||
const launchState =
|
||||
evidence?.launchState ??
|
||||
deriveMemberLaunchState({
|
||||
hardFailure: evidence?.hardFailure,
|
||||
bootstrapConfirmed: evidence?.bootstrapConfirmed,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
agentToolAccepted: evidence?.agentToolAccepted,
|
||||
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
|
||||
});
|
||||
const hardFailure = evidence?.hardFailure === true || launchState === 'failed_to_start';
|
||||
const healBootstrapConfirmedProvisionedButNotAlive =
|
||||
canHealBootstrapConfirmedProvisionedButNotAliveFailure(evidence ?? undefined);
|
||||
const runtimeAlive = healBootstrapConfirmedProvisionedButNotAlive || strongRuntimeAlive;
|
||||
const launchState = healBootstrapConfirmedProvisionedButNotAlive
|
||||
? 'confirmed_alive'
|
||||
: (evidence?.launchState ??
|
||||
deriveMemberLaunchState({
|
||||
hardFailure: evidence?.hardFailure,
|
||||
bootstrapConfirmed: evidence?.bootstrapConfirmed,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
agentToolAccepted: evidence?.agentToolAccepted,
|
||||
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
|
||||
}));
|
||||
const hardFailure =
|
||||
!healBootstrapConfirmedProvisionedButNotAlive &&
|
||||
(evidence?.hardFailure === true || launchState === 'failed_to_start');
|
||||
const hardFailureReason = hardFailure ? evidence?.hardFailureReason : undefined;
|
||||
const firstSpawnAcceptedAt = evidence
|
||||
? resolveOpenCodeSecondaryFirstSpawnAcceptedAt(evidence, params.updatedAt)
|
||||
|
|
@ -340,7 +369,7 @@ function createSecondaryLaneMemberState(
|
|||
laneOwnerProviderId: providerId,
|
||||
launchState,
|
||||
agentToolAccepted: evidence?.agentToolAccepted === true,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
|
||||
hardFailure,
|
||||
hardFailureReason,
|
||||
|
|
@ -373,7 +402,7 @@ function createSecondaryLaneMemberState(
|
|||
firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
||||
runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||
lastRuntimeAliveAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||
lastRuntimeAliveAt: runtimeAlive ? params.updatedAt : undefined,
|
||||
lastEvaluatedAt: params.updatedAt,
|
||||
sources: strongRuntimeAlive
|
||||
? {
|
||||
|
|
@ -412,7 +441,10 @@ function summarizeMembers(
|
|||
pendingCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'confirmed_alive') {
|
||||
if (
|
||||
entry.launchState === 'confirmed_alive' ||
|
||||
canHealBootstrapConfirmedProvisionedButNotAliveFailure(entry)
|
||||
) {
|
||||
confirmedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const logger = createLogger('IPC:cliInstaller');
|
|||
let service: CliInstallerService;
|
||||
const statusInFlight = new Map<CliInstallerProviderStatusMode, Promise<CliInstallationStatus>>();
|
||||
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
||||
let providerRuntimeRequestTail: Promise<void> = Promise.resolve();
|
||||
const cachedStatus = new Map<
|
||||
CliInstallerProviderStatusMode,
|
||||
{ value: CliInstallationStatus; at: number }
|
||||
|
|
@ -110,11 +111,21 @@ function canUseStatusForCacheKey(
|
|||
);
|
||||
}
|
||||
|
||||
function runProviderRuntimeRequest<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const request = providerRuntimeRequestTail.then(operation, operation);
|
||||
providerRuntimeRequestTail = request.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes CLI installer handlers with the service instance.
|
||||
*/
|
||||
export function initializeCliInstallerHandlers(installerService: CliInstallerService): void {
|
||||
service = installerService;
|
||||
providerRuntimeRequestTail = Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -255,8 +266,7 @@ async function handleGetProviderStatus(
|
|||
}
|
||||
|
||||
const generation = statusCacheGeneration;
|
||||
const request = service
|
||||
.getProviderStatus(providerId)
|
||||
const request = runProviderRuntimeRequest(() => service.getProviderStatus(providerId))
|
||||
.then((status) => {
|
||||
if (generation === statusCacheGeneration) {
|
||||
patchCachedProviderStatus(status);
|
||||
|
|
@ -296,7 +306,7 @@ async function handleVerifyProviderModels(
|
|||
): Promise<IpcResult<CliProviderStatus | null>> {
|
||||
try {
|
||||
const generation = statusCacheGeneration;
|
||||
const status = await service.verifyProviderModels(providerId);
|
||||
const status = await runProviderRuntimeRequest(() => service.verifyProviderModels(providerId));
|
||||
if (generation === statusCacheGeneration) {
|
||||
patchCachedProviderStatus(status);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
type EnhancedAIChunk,
|
||||
type EnhancedChunk,
|
||||
isEnhancedAIChunk,
|
||||
isHumanAuthoredParsedUserMessage,
|
||||
type ParsedMessage,
|
||||
type Process,
|
||||
type SemanticStepGroup,
|
||||
|
|
@ -85,19 +86,7 @@ export async function buildSubagentDetail(
|
|||
// Build chunks with semantic steps
|
||||
const chunks = buildChunksFn(parsedSession.messages, nestedSubagents);
|
||||
|
||||
// Extract description (try to get from first user message)
|
||||
let description = 'Subagent';
|
||||
if (parsedSession.messages.length > 0) {
|
||||
const firstUserMsg = parsedSession.messages.find(
|
||||
(m) => m.type === 'user' && typeof m.content === 'string'
|
||||
);
|
||||
if (firstUserMsg && typeof firstUserMsg.content === 'string') {
|
||||
description = firstUserMsg.content.substring(0, 100);
|
||||
if (firstUserMsg.content.length > 100) {
|
||||
description += '...';
|
||||
}
|
||||
}
|
||||
}
|
||||
const description = deriveSubagentDescription(parsedSession.messages);
|
||||
|
||||
// Calculate timing
|
||||
const times = parsedSession.messages.map((m) => m.timestamp.getTime());
|
||||
|
|
@ -144,3 +133,19 @@ export async function buildSubagentDetail(
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveSubagentDescription(messages: ParsedMessage[]): string {
|
||||
const firstUserMsg = messages.find((message) => {
|
||||
return isHumanAuthoredParsedUserMessage(message) && typeof message.content === 'string';
|
||||
});
|
||||
|
||||
if (!firstUserMsg || typeof firstUserMsg.content !== 'string') {
|
||||
return 'Subagent';
|
||||
}
|
||||
|
||||
let description = firstUserMsg.content.substring(0, 100);
|
||||
if (firstUserMsg.content.length > 100) {
|
||||
description += '...';
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,15 @@
|
|||
* - synthetic assistant messages (model='<synthetic>')
|
||||
*/
|
||||
|
||||
import { HARD_NOISE_TAGS } from '@main/constants/messageTags';
|
||||
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
||||
import { type ChatHistoryEntry, type ContentBlock } from '@main/types';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import {
|
||||
classifyUserTurnProvenance,
|
||||
isDisplayableTeammateProtocol,
|
||||
isSyntheticReplayNoise,
|
||||
} from '@shared/utils/userTurnProvenance';
|
||||
import * as readline from 'readline';
|
||||
|
||||
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
|
||||
|
|
@ -40,11 +46,6 @@ function byteLen(chunk: string): number {
|
|||
return Buffer.byteLength(chunk, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard noise tags - user messages with ONLY these tags are filtered out.
|
||||
*/
|
||||
const HARD_NOISE_TAGS = ['<local-command-caveat>', '<system-reminder>'];
|
||||
|
||||
/**
|
||||
* Hard noise entry types - these types are always filtered out.
|
||||
*/
|
||||
|
|
@ -193,10 +194,30 @@ export class SessionContentFilter {
|
|||
const userEntry = entry as {
|
||||
message?: { content?: string | ContentBlock[] };
|
||||
isMeta?: boolean;
|
||||
isSynthetic?: boolean;
|
||||
isReplay?: boolean;
|
||||
toolUseResult?: unknown;
|
||||
sourceToolUseID?: unknown;
|
||||
origin?: { kind?: string };
|
||||
protocolKind?: string;
|
||||
};
|
||||
const content = userEntry.message?.content;
|
||||
const isMeta = userEntry.isMeta;
|
||||
|
||||
if (isSyntheticReplayNoise(userEntry)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const provenance = classifyUserTurnProvenance(userEntry);
|
||||
if (
|
||||
provenance !== 'human' &&
|
||||
provenance !== 'tool-result' &&
|
||||
provenance !== 'local-command-output' &&
|
||||
!isDisplayableTeammateProtocol(userEntry)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Internal user messages (tool results) - part of AI response flow
|
||||
// These ARE displayable as they're part of AIChunks
|
||||
if (isMeta === true) {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,16 @@
|
|||
* - Link subagents to parent Task tool calls
|
||||
*/
|
||||
|
||||
import { type ParsedMessage, type Process, type SessionMetrics, type ToolCall } from '@main/types';
|
||||
import {
|
||||
isHumanAuthoredParsedUserMessage,
|
||||
type ParsedMessage,
|
||||
type Process,
|
||||
type SessionMetrics,
|
||||
type ToolCall,
|
||||
} from '@main/types';
|
||||
import { calculateMetrics, checkMessagesOngoing, parseJsonlFile } from '@main/utils/jsonl';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isDisplayableTeammateProtocol } from '@shared/utils/userTurnProvenance';
|
||||
import * as path from 'path';
|
||||
|
||||
import { type ProjectScanner } from './ProjectScanner';
|
||||
|
|
@ -142,8 +149,9 @@ export class SubagentResolver {
|
|||
* - isSidechain: true (all subagents have this)
|
||||
*/
|
||||
private isWarmupSubagent(messages: ParsedMessage[]): boolean {
|
||||
// Find the first user message
|
||||
const firstUserMessage = messages.find((m) => m.type === 'user');
|
||||
// Find the first authored user message. Synthetic SDK replays can also be
|
||||
// user-role rows, but they must not decide whether a subagent is warmup.
|
||||
const firstUserMessage = messages.find((m) => this.isAuthoredUserMessage(m));
|
||||
if (!firstUserMessage) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -158,12 +166,37 @@ export class SubagentResolver {
|
|||
* Used for deterministic matching of team member files to their spawning Task calls.
|
||||
*/
|
||||
private extractTeammateId(messages: ParsedMessage[]): string | undefined {
|
||||
const firstUserMessage = messages.find((m) => m.type === 'user');
|
||||
if (!firstUserMessage) return undefined;
|
||||
for (const message of messages) {
|
||||
if (!this.isAuthoredUserMessage(message)) continue;
|
||||
|
||||
const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : '';
|
||||
const match = /<teammate-message\s[^>]*?\bteammate_id="([^"]+)"/.exec(text);
|
||||
return match?.[1];
|
||||
const text = this.extractUserText(message);
|
||||
const normalized = this.stripTranscriptSpeakerPrefix(text);
|
||||
const match = /<teammate-message\s[^>]*?\bteammate_id="([^"]+)"/.exec(normalized);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isAuthoredUserMessage(message: ParsedMessage): boolean {
|
||||
return isHumanAuthoredParsedUserMessage(message) || isDisplayableTeammateProtocol(message);
|
||||
}
|
||||
|
||||
private extractUserText(message: ParsedMessage): string {
|
||||
if (typeof message.content === 'string') {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
return message.content
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private stripTranscriptSpeakerPrefix(text: string): string {
|
||||
return text.replace(/^(?:Human|User):\s*/i, '').trimStart();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ const MAX_TARBALL_BYTES = 250 * 1024 * 1024;
|
|||
const MAX_BINARY_BYTES = 350 * 1024 * 1024;
|
||||
const FETCH_TIMEOUT_MS = 60_000;
|
||||
const VERSION_TIMEOUT_MS = 10_000;
|
||||
const VERSION_PROBE_SUCCESS_CACHE_TTL_MS = 30_000;
|
||||
const VERSION_PROBE_FAILURE_CACHE_TTL_MS = 5_000;
|
||||
const RUNTIME_STATUS_SUCCESS_CACHE_TTL_MS = 30_000;
|
||||
const RUNTIME_STATUS_FAILURE_CACHE_TTL_MS = 5_000;
|
||||
|
||||
interface NpmPackageMetadata {
|
||||
name?: string;
|
||||
|
|
@ -97,15 +101,8 @@ export async function resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(): Prom
|
|||
if (!binaryPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await execCli(binaryPath, ['--version'], {
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
return binaryPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const version = await probeOpenCodeBinaryVersionCached(binaryPath);
|
||||
return version.ok ? binaryPath : null;
|
||||
}
|
||||
|
||||
function getExecutableName(): string {
|
||||
|
|
@ -173,6 +170,32 @@ type VerifiedOpenCodeBinaryProbe =
|
|||
| { ok: true; binaryPath: string; version: string | null }
|
||||
| { ok: false; firstFailure: { binaryPath: string; error: string } | null };
|
||||
|
||||
interface CachedVersionProbe {
|
||||
result: OpenCodeBinaryVersionProbe;
|
||||
cachedAt: number;
|
||||
ttlMs: number;
|
||||
}
|
||||
|
||||
interface CachedPathProbe {
|
||||
result: VerifiedOpenCodeBinaryProbe;
|
||||
cachedAt: number;
|
||||
ttlMs: number;
|
||||
}
|
||||
|
||||
interface CachedRuntimeBinaryResolve {
|
||||
binaryPath: string | null;
|
||||
cachedAt: number;
|
||||
ttlMs: number;
|
||||
}
|
||||
|
||||
const versionProbeCache = new Map<string, CachedVersionProbe>();
|
||||
const versionProbeInFlight = new Map<string, Promise<OpenCodeBinaryVersionProbe>>();
|
||||
const pathProbeCache = new Map<string, CachedPathProbe>();
|
||||
const pathProbeInFlight = new Map<string, Promise<VerifiedOpenCodeBinaryProbe>>();
|
||||
const runtimeBinaryResolveCache = new Map<string, CachedRuntimeBinaryResolve>();
|
||||
const runtimeBinaryResolveInFlight = new Map<string, Promise<string | null>>();
|
||||
let runtimeResolverCacheGeneration = 0;
|
||||
|
||||
async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeBinaryVersionProbe> {
|
||||
try {
|
||||
const { stdout } = await execCli(binaryPath, ['--version'], {
|
||||
|
|
@ -190,6 +213,45 @@ function normalizeBinaryCandidateForCompare(binaryPath: string): string {
|
|||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function getVersionProbeTtlMs(result: OpenCodeBinaryVersionProbe): number {
|
||||
return result.ok ? VERSION_PROBE_SUCCESS_CACHE_TTL_MS : VERSION_PROBE_FAILURE_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
async function probeOpenCodeBinaryVersionCached(
|
||||
binaryPath: string
|
||||
): Promise<OpenCodeBinaryVersionProbe> {
|
||||
const cacheKey = normalizeBinaryCandidateForCompare(binaryPath);
|
||||
const cached = versionProbeCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.cachedAt < cached.ttlMs) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const inFlight = versionProbeInFlight.get(cacheKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const cacheGeneration = runtimeResolverCacheGeneration;
|
||||
const request = probeOpenCodeBinaryVersion(binaryPath)
|
||||
.then((result) => {
|
||||
if (cacheGeneration === runtimeResolverCacheGeneration) {
|
||||
versionProbeCache.set(cacheKey, {
|
||||
result,
|
||||
cachedAt: Date.now(),
|
||||
ttlMs: getVersionProbeTtlMs(result),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
if (versionProbeInFlight.get(cacheKey) === request) {
|
||||
versionProbeInFlight.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
versionProbeInFlight.set(cacheKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function probeFirstWorkingOpenCodeBinaryCandidate(
|
||||
candidates: string[],
|
||||
seen: Set<string>,
|
||||
|
|
@ -202,7 +264,7 @@ async function probeFirstWorkingOpenCodeBinaryCandidate(
|
|||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
const version = await probeOpenCodeBinaryVersion(binaryPath);
|
||||
const version = await probeOpenCodeBinaryVersionCached(binaryPath);
|
||||
if (version.ok) {
|
||||
return { ok: true, binaryPath, version: version.version };
|
||||
}
|
||||
|
|
@ -217,6 +279,22 @@ interface OpenCodeRuntimeBinaryResolveOptions {
|
|||
includeShellEnv?: boolean;
|
||||
}
|
||||
|
||||
function getPathProbeCacheKey(options: OpenCodeRuntimeBinaryResolveOptions = {}): string {
|
||||
if (options.includeShellEnv === false) {
|
||||
return 'no-shell-env';
|
||||
}
|
||||
|
||||
return `shell-env:${options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS}`;
|
||||
}
|
||||
|
||||
function getPathProbeTtlMs(result: VerifiedOpenCodeBinaryProbe): number {
|
||||
return result.ok ? VERSION_PROBE_SUCCESS_CACHE_TTL_MS : VERSION_PROBE_FAILURE_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
function getRuntimeBinaryResolveTtlMs(binaryPath: string | null): number {
|
||||
return binaryPath ? VERSION_PROBE_SUCCESS_CACHE_TTL_MS : VERSION_PROBE_FAILURE_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
async function probeFirstWorkingPathOpenCodeBinary(
|
||||
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||
): Promise<VerifiedOpenCodeBinaryProbe> {
|
||||
|
|
@ -268,20 +346,93 @@ async function probeFirstWorkingPathOpenCodeBinary(
|
|||
);
|
||||
}
|
||||
|
||||
async function probeFirstWorkingPathOpenCodeBinaryCached(
|
||||
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||
): Promise<VerifiedOpenCodeBinaryProbe> {
|
||||
const cacheKey = getPathProbeCacheKey(options);
|
||||
const cached = pathProbeCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.cachedAt < cached.ttlMs) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const inFlight = pathProbeInFlight.get(cacheKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const cacheGeneration = runtimeResolverCacheGeneration;
|
||||
const request = probeFirstWorkingPathOpenCodeBinary(options)
|
||||
.then((result) => {
|
||||
if (cacheGeneration === runtimeResolverCacheGeneration) {
|
||||
pathProbeCache.set(cacheKey, {
|
||||
result,
|
||||
cachedAt: Date.now(),
|
||||
ttlMs: getPathProbeTtlMs(result),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
if (pathProbeInFlight.get(cacheKey) === request) {
|
||||
pathProbeInFlight.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
pathProbeInFlight.set(cacheKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function resolveVerifiedPathOpenCodeBinaryPath(
|
||||
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||
): Promise<string | null> {
|
||||
const result = await probeFirstWorkingPathOpenCodeBinary(options);
|
||||
const result = await probeFirstWorkingPathOpenCodeBinaryCached(options);
|
||||
return result.ok ? result.binaryPath : null;
|
||||
}
|
||||
|
||||
export function clearOpenCodeRuntimeBinaryResolverCache(): void {
|
||||
runtimeResolverCacheGeneration += 1;
|
||||
versionProbeCache.clear();
|
||||
versionProbeInFlight.clear();
|
||||
pathProbeCache.clear();
|
||||
pathProbeInFlight.clear();
|
||||
runtimeBinaryResolveCache.clear();
|
||||
runtimeBinaryResolveInFlight.clear();
|
||||
}
|
||||
|
||||
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
||||
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||
): Promise<string | null> {
|
||||
return (
|
||||
const cacheKey = getPathProbeCacheKey(options);
|
||||
const cached = runtimeBinaryResolveCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.cachedAt < cached.ttlMs) {
|
||||
return cached.binaryPath;
|
||||
}
|
||||
|
||||
const inFlight = runtimeBinaryResolveInFlight.get(cacheKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const cacheGeneration = runtimeResolverCacheGeneration;
|
||||
const request = (async () =>
|
||||
(await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ??
|
||||
(await resolveVerifiedPathOpenCodeBinaryPath(options))
|
||||
);
|
||||
(await resolveVerifiedPathOpenCodeBinaryPath(options)))()
|
||||
.then((binaryPath) => {
|
||||
if (cacheGeneration === runtimeResolverCacheGeneration) {
|
||||
runtimeBinaryResolveCache.set(cacheKey, {
|
||||
binaryPath,
|
||||
cachedAt: Date.now(),
|
||||
ttlMs: getRuntimeBinaryResolveTtlMs(binaryPath),
|
||||
});
|
||||
}
|
||||
return binaryPath;
|
||||
})
|
||||
.finally(() => {
|
||||
if (runtimeBinaryResolveInFlight.get(cacheKey) === request) {
|
||||
runtimeBinaryResolveInFlight.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
runtimeBinaryResolveInFlight.set(cacheKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function isLinuxMuslRuntime(): boolean {
|
||||
|
|
@ -511,23 +662,52 @@ export class OpenCodeRuntimeInstallerService {
|
|||
private mainWindow: BrowserWindow | null = null;
|
||||
private installPromise: Promise<OpenCodeRuntimeStatus> | null = null;
|
||||
private latestStatus: OpenCodeRuntimeStatus | null = null;
|
||||
private latestStatusAt = 0;
|
||||
private statusPromise: Promise<OpenCodeRuntimeStatus> | null = null;
|
||||
private statusCacheGeneration = 0;
|
||||
|
||||
setMainWindow(win: BrowserWindow | null): void {
|
||||
this.mainWindow = win;
|
||||
}
|
||||
|
||||
invalidateStatusCache(): void {
|
||||
this.statusCacheGeneration += 1;
|
||||
this.latestStatus = null;
|
||||
this.latestStatusAt = 0;
|
||||
this.statusPromise = null;
|
||||
clearOpenCodeRuntimeBinaryResolverCache();
|
||||
}
|
||||
|
||||
async getStatus(): Promise<OpenCodeRuntimeStatus> {
|
||||
if (this.installPromise && this.latestStatus) {
|
||||
return this.latestStatus;
|
||||
}
|
||||
if (this.installPromise) {
|
||||
return this.installPromise;
|
||||
}
|
||||
|
||||
if (this.latestStatus && Date.now() - this.latestStatusAt < this.getStatusCacheTtlMs()) {
|
||||
return this.latestStatus;
|
||||
}
|
||||
|
||||
if (this.statusPromise) {
|
||||
return this.statusPromise;
|
||||
}
|
||||
|
||||
const statusCacheGeneration = this.statusCacheGeneration;
|
||||
const request = this.resolveStatus(statusCacheGeneration).finally(() => {
|
||||
if (this.statusPromise === request) {
|
||||
this.statusPromise = null;
|
||||
}
|
||||
});
|
||||
this.statusPromise = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
private async resolveStatus(statusCacheGeneration: number): Promise<OpenCodeRuntimeStatus> {
|
||||
const appManagedStatus = await this.getAppManagedStatus();
|
||||
if (appManagedStatus.installed) {
|
||||
this.latestStatus = appManagedStatus;
|
||||
this.rememberStatusIfCurrent(appManagedStatus, statusCacheGeneration);
|
||||
return appManagedStatus;
|
||||
}
|
||||
|
||||
|
|
@ -538,7 +718,7 @@ export class OpenCodeRuntimeInstallerService {
|
|||
appManagedStatus.state !== 'failed'
|
||||
? pathStatus
|
||||
: appManagedStatus;
|
||||
this.latestStatus = status;
|
||||
this.rememberStatusIfCurrent(status, statusCacheGeneration);
|
||||
return status;
|
||||
}
|
||||
|
||||
|
|
@ -553,10 +733,31 @@ export class OpenCodeRuntimeInstallerService {
|
|||
}
|
||||
|
||||
private publish(status: OpenCodeRuntimeStatus): void {
|
||||
this.latestStatus = status;
|
||||
this.statusCacheGeneration += 1;
|
||||
this.rememberStatus(status);
|
||||
safeSendToRenderer(this.mainWindow, CHANNEL, status);
|
||||
}
|
||||
|
||||
private rememberStatusIfCurrent(
|
||||
status: OpenCodeRuntimeStatus,
|
||||
statusCacheGeneration: number
|
||||
): void {
|
||||
if (statusCacheGeneration === this.statusCacheGeneration) {
|
||||
this.rememberStatus(status);
|
||||
}
|
||||
}
|
||||
|
||||
private rememberStatus(status: OpenCodeRuntimeStatus): void {
|
||||
this.latestStatus = status;
|
||||
this.latestStatusAt = Date.now();
|
||||
}
|
||||
|
||||
private getStatusCacheTtlMs(): number {
|
||||
return this.latestStatus?.installed === true
|
||||
? RUNTIME_STATUS_SUCCESS_CACHE_TTL_MS
|
||||
: RUNTIME_STATUS_FAILURE_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
private publishProgress(progress: OpenCodeRuntimeInstallProgress): void {
|
||||
this.publish({
|
||||
installed: false,
|
||||
|
|
@ -571,32 +772,28 @@ export class OpenCodeRuntimeInstallerService {
|
|||
if (!isAbsoluteExistingFile(manifest?.binaryPath)) {
|
||||
return { installed: false, source: 'missing', state: 'idle' };
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execCli(manifest.binaryPath, ['--version'], {
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
const version = await probeOpenCodeBinaryVersionCached(manifest.binaryPath);
|
||||
if (version.ok) {
|
||||
return {
|
||||
installed: true,
|
||||
binaryPath: manifest.binaryPath,
|
||||
version: stdout.trim() || manifest.version,
|
||||
version: version.version ?? manifest.version,
|
||||
source: 'app-managed',
|
||||
state: 'ready',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
installed: false,
|
||||
binaryPath: manifest.binaryPath,
|
||||
version: manifest.version,
|
||||
source: 'app-managed',
|
||||
state: 'failed',
|
||||
error: getErrorMessage(error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
installed: false,
|
||||
binaryPath: manifest.binaryPath,
|
||||
version: manifest.version,
|
||||
source: 'app-managed',
|
||||
state: 'failed',
|
||||
error: version.error,
|
||||
};
|
||||
}
|
||||
|
||||
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
|
||||
const result = await probeFirstWorkingPathOpenCodeBinary();
|
||||
const result = await probeFirstWorkingPathOpenCodeBinaryCached();
|
||||
if (result.ok) {
|
||||
return {
|
||||
installed: true,
|
||||
|
|
@ -687,6 +884,7 @@ export class OpenCodeRuntimeInstallerService {
|
|||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
clearOpenCodeRuntimeBinaryResolverCache();
|
||||
|
||||
const status: OpenCodeRuntimeStatus = {
|
||||
installed: true,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
isHumanAuthoredParsedUserMessage,
|
||||
isParsedInternalUserMessage,
|
||||
isParsedRealUserMessage,
|
||||
type ParsedMessage,
|
||||
|
|
@ -178,8 +179,9 @@ export class SessionParser {
|
|||
for (let i = userMsgIndex + 1; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
|
||||
// Stop at next user message
|
||||
if (msg.type === 'user') break;
|
||||
// Stop at the next human-authored user turn. Structured protocol/meta
|
||||
// rows can use type='user' but must not split the assistant response.
|
||||
if (isHumanAuthoredParsedUserMessage(msg)) break;
|
||||
|
||||
// Include assistant responses
|
||||
if (msg.type === 'assistant') {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ const logger = createLogger('ClaudeMultimodelBridgeService');
|
|||
|
||||
const PROVIDER_STATUS_TIMEOUT_MS = 90_000;
|
||||
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 30_000;
|
||||
const LEGACY_FALLBACK_PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 5_000;
|
||||
const OPENCODE_FALLBACK_PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 12_000;
|
||||
const LEGACY_PROVIDER_AUTH_TIMEOUT_MS = 15_000;
|
||||
const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
|
||||
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||
const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||
|
|
@ -112,34 +115,35 @@ interface RuntimeProviderModelCatalogResponse {
|
|||
};
|
||||
}
|
||||
|
||||
interface ProviderStatusPayloadResponse {
|
||||
supported?: boolean;
|
||||
authenticated?: boolean;
|
||||
authMethod?: string | null;
|
||||
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
|
||||
canLoginFromUi?: boolean;
|
||||
statusMessage?: string | null;
|
||||
detailMessage?: string | null;
|
||||
capabilities?: {
|
||||
teamLaunch?: boolean;
|
||||
oneShot?: boolean;
|
||||
extensions?: RuntimeExtensionCapabilitiesResponse;
|
||||
};
|
||||
backend?: {
|
||||
kind?: string;
|
||||
label?: string;
|
||||
endpointLabel?: string | null;
|
||||
projectId?: string | null;
|
||||
authMethodDetail?: string | null;
|
||||
} | null;
|
||||
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
|
||||
subscriptionRateLimits?: RuntimeSubscriptionRateLimitSnapshotResponse | null;
|
||||
}
|
||||
|
||||
interface ProviderStatusCommandResponse {
|
||||
schemaVersion?: number;
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
supported?: boolean;
|
||||
authenticated?: boolean;
|
||||
authMethod?: string | null;
|
||||
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
|
||||
canLoginFromUi?: boolean;
|
||||
statusMessage?: string | null;
|
||||
detailMessage?: string | null;
|
||||
capabilities?: {
|
||||
teamLaunch?: boolean;
|
||||
oneShot?: boolean;
|
||||
extensions?: RuntimeExtensionCapabilitiesResponse;
|
||||
};
|
||||
backend?: {
|
||||
kind?: string;
|
||||
label?: string;
|
||||
endpointLabel?: string | null;
|
||||
projectId?: string | null;
|
||||
authMethodDetail?: string | null;
|
||||
} | null;
|
||||
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
|
||||
subscriptionRateLimits?: RuntimeSubscriptionRateLimitSnapshotResponse | null;
|
||||
}
|
||||
>;
|
||||
provider?: string;
|
||||
status?: ProviderStatusPayloadResponse;
|
||||
providers?: Record<string, ProviderStatusPayloadResponse>;
|
||||
}
|
||||
|
||||
interface ProviderModelsCommandResponse {
|
||||
|
|
@ -879,6 +883,171 @@ export class ClaudeMultimodelBridgeService {
|
|||
return lower.includes('timed out') || lower.includes('timeout');
|
||||
}
|
||||
|
||||
private shouldUseLegacyProviderTimeoutFallback(providerId: CliProviderId): boolean {
|
||||
return providerId === 'anthropic' || providerId === 'codex' || providerId === 'opencode';
|
||||
}
|
||||
|
||||
private getProviderStatusRuntimeTimeout(
|
||||
providerId: CliProviderId,
|
||||
options: { summary?: boolean; timeoutMs?: number }
|
||||
): number {
|
||||
if (options.summary && this.shouldUseLegacyProviderTimeoutFallback(providerId)) {
|
||||
const fallbackTimeout =
|
||||
providerId === 'opencode'
|
||||
? OPENCODE_FALLBACK_PROVIDER_STATUS_SUMMARY_TIMEOUT_MS
|
||||
: LEGACY_FALLBACK_PROVIDER_STATUS_SUMMARY_TIMEOUT_MS;
|
||||
return Math.min(options.timeoutMs ?? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS, fallbackTimeout);
|
||||
}
|
||||
return (
|
||||
options.timeoutMs ??
|
||||
(options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS)
|
||||
);
|
||||
}
|
||||
|
||||
private getLegacyProviderStatusPayload(
|
||||
providerId: CliProviderId,
|
||||
parsed: ProviderStatusCommandResponse
|
||||
): ProviderStatusPayloadResponse | undefined {
|
||||
if (parsed.providers?.[providerId]) {
|
||||
return parsed.providers[providerId];
|
||||
}
|
||||
return parsed.provider === providerId ? parsed.status : undefined;
|
||||
}
|
||||
|
||||
private mergeLegacyProviderStatusPayload(
|
||||
provider: CliProviderStatus,
|
||||
runtimeStatus: ProviderStatusPayloadResponse | undefined
|
||||
): CliProviderStatus {
|
||||
if (!runtimeStatus) {
|
||||
return provider;
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
supported: runtimeStatus.supported === true,
|
||||
authenticated: runtimeStatus.authenticated === true,
|
||||
authMethod: runtimeStatus.authMethod ?? null,
|
||||
verificationState: runtimeStatus.verificationState ?? 'unknown',
|
||||
statusMessage: runtimeStatus.statusMessage ?? null,
|
||||
detailMessage: runtimeStatus.detailMessage ?? null,
|
||||
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
|
||||
capabilities: {
|
||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||
extensions: mapRuntimeExtensionCapabilities(
|
||||
provider.providerId,
|
||||
runtimeStatus.capabilities?.extensions
|
||||
),
|
||||
},
|
||||
backend: runtimeStatus.backend?.kind
|
||||
? {
|
||||
kind: runtimeStatus.backend.kind,
|
||||
label: runtimeStatus.backend.label ?? runtimeStatus.backend.kind,
|
||||
endpointLabel: runtimeStatus.backend.endpointLabel ?? null,
|
||||
projectId: runtimeStatus.backend.projectId ?? null,
|
||||
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async getProviderStatusFromLegacyProbes(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId
|
||||
): Promise<CliProviderStatus> {
|
||||
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
|
||||
let provider = createDefaultProviderStatus(providerId);
|
||||
let fulfilledProbeCount = 0;
|
||||
|
||||
const authStatusPromise =
|
||||
providerId === 'anthropic' || providerId === 'codex'
|
||||
? execCli(binaryPath, ['auth', 'status', '--json', '--provider', providerId], {
|
||||
timeout: LEGACY_PROVIDER_AUTH_TIMEOUT_MS,
|
||||
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
||||
env,
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const modelListPromise = execCli(
|
||||
binaryPath,
|
||||
['model', 'list', '--json', '--provider', providerId],
|
||||
{
|
||||
timeout: PROVIDER_MODELS_TIMEOUT_MS,
|
||||
maxBuffer: PROVIDER_MODELS_MAX_BUFFER_BYTES,
|
||||
env,
|
||||
}
|
||||
);
|
||||
|
||||
const [authStatusResult, modelListResult] = await Promise.allSettled([
|
||||
authStatusPromise,
|
||||
modelListPromise,
|
||||
]);
|
||||
|
||||
if (authStatusResult.status === 'fulfilled' && authStatusResult.value) {
|
||||
const parsed = extractJsonObject<ProviderStatusCommandResponse>(
|
||||
authStatusResult.value.stdout
|
||||
);
|
||||
provider = this.mergeLegacyProviderStatusPayload(
|
||||
provider,
|
||||
this.getLegacyProviderStatusPayload(providerId, parsed)
|
||||
);
|
||||
fulfilledProbeCount += 1;
|
||||
} else if (authStatusResult.status === 'rejected') {
|
||||
logger.warn(
|
||||
`Legacy provider auth status unavailable for ${providerId}: ${
|
||||
authStatusResult.reason instanceof Error
|
||||
? authStatusResult.reason.message
|
||||
: String(authStatusResult.reason)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
if (modelListResult.status === 'fulfilled') {
|
||||
const parsed = extractJsonObject<ProviderModelsCommandResponse>(modelListResult.value.stdout);
|
||||
const runtimeModels = extractModelIds(parsed.providers?.[providerId]?.models);
|
||||
if (runtimeModels.length > 0) {
|
||||
provider = {
|
||||
...provider,
|
||||
models: runtimeModels,
|
||||
};
|
||||
}
|
||||
fulfilledProbeCount += 1;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Legacy provider models unavailable for ${providerId}: ${
|
||||
modelListResult.reason instanceof Error
|
||||
? modelListResult.reason.message
|
||||
: String(modelListResult.reason)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
if (fulfilledProbeCount === 0) {
|
||||
throw new Error(`Legacy provider probes unavailable for ${providerId}`);
|
||||
}
|
||||
|
||||
return providerConnectionService.enrichProviderStatus(
|
||||
this.applyConnectionIssue(provider, connectionIssues)
|
||||
);
|
||||
}
|
||||
|
||||
private async getProviderStatusFromLegacyProbesOrError(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId,
|
||||
originalError: unknown
|
||||
): Promise<CliProviderStatus> {
|
||||
try {
|
||||
return await this.getProviderStatusFromLegacyProbes(binaryPath, providerId);
|
||||
} catch (fallbackError) {
|
||||
logger.warn(
|
||||
`Legacy provider probes unavailable for ${providerId}: ${
|
||||
fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
|
||||
}`
|
||||
);
|
||||
return createRuntimeStatusErrorProviderStatus(providerId, originalError);
|
||||
}
|
||||
}
|
||||
|
||||
private mapRuntimeProviderStatus(
|
||||
providerId: CliProviderId,
|
||||
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
|
||||
|
|
@ -1024,9 +1193,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
if (options.summary) {
|
||||
args.push('--summary');
|
||||
}
|
||||
const timeout =
|
||||
options.timeoutMs ??
|
||||
(options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS);
|
||||
const timeout = this.getProviderStatusRuntimeTimeout(providerId, options);
|
||||
const { stdout } = await execCli(binaryPath, args, {
|
||||
timeout,
|
||||
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
||||
|
|
@ -1081,6 +1248,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
}
|
||||
})
|
||||
);
|
||||
failures.sort((a, b) => providerIds.indexOf(a.providerId) - providerIds.indexOf(b.providerId));
|
||||
|
||||
if (failures.length === 0) {
|
||||
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
||||
|
|
@ -1091,10 +1259,18 @@ export class ClaudeMultimodelBridgeService {
|
|||
logger.warn(
|
||||
`Provider-scoped runtime status timed out for ${failures
|
||||
.map(({ providerId }) => providerId)
|
||||
.join(', ')}; using error provider statuses without slower fallback probes`
|
||||
.join(', ')}; falling back to scoped legacy provider probes`
|
||||
);
|
||||
for (const { providerId, error } of failures) {
|
||||
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
|
||||
const fallbackProviders = await Promise.all(
|
||||
failures.map(async ({ providerId, error }) => ({
|
||||
providerId,
|
||||
provider: this.shouldUseLegacyProviderTimeoutFallback(providerId)
|
||||
? await this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, error)
|
||||
: createRuntimeStatusErrorProviderStatus(providerId, error),
|
||||
}))
|
||||
);
|
||||
for (const { providerId, provider } of fallbackProviders) {
|
||||
providers.set(providerId, provider);
|
||||
}
|
||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
||||
|
|
@ -1109,8 +1285,18 @@ export class ClaudeMultimodelBridgeService {
|
|||
.join(', ')}; using partial provider statuses`
|
||||
);
|
||||
|
||||
for (const { providerId, error } of failures) {
|
||||
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
|
||||
const fallbackProviders = await Promise.all(
|
||||
failures.map(async ({ providerId, error }) => ({
|
||||
providerId,
|
||||
provider:
|
||||
this.isRuntimeStatusTimeoutError(error) &&
|
||||
this.shouldUseLegacyProviderTimeoutFallback(providerId)
|
||||
? await this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, error)
|
||||
: createRuntimeStatusErrorProviderStatus(providerId, error),
|
||||
}))
|
||||
);
|
||||
for (const { providerId, provider } of fallbackProviders) {
|
||||
providers.set(providerId, provider);
|
||||
}
|
||||
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
||||
|
|
@ -1322,6 +1508,17 @@ export class ClaudeMultimodelBridgeService {
|
|||
try {
|
||||
return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId);
|
||||
} catch (fullError) {
|
||||
if (
|
||||
this.isRuntimeStatusTimeoutError(fullError) &&
|
||||
this.shouldUseLegacyProviderTimeoutFallback(providerId)
|
||||
) {
|
||||
logger.warn(
|
||||
`Provider-scoped full runtime status timed out for ${providerId}, falling back to scoped legacy probes: ${
|
||||
fullError instanceof Error ? fullError.message : String(fullError)
|
||||
}`
|
||||
);
|
||||
return this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, fullError);
|
||||
}
|
||||
logger.warn(
|
||||
`Provider-scoped full runtime status unavailable for ${providerId}, returning scoped error: ${
|
||||
fullError instanceof Error ? fullError.message : String(fullError)
|
||||
|
|
@ -1332,10 +1529,21 @@ export class ClaudeMultimodelBridgeService {
|
|||
}
|
||||
|
||||
logger.warn(
|
||||
`Provider-scoped summary runtime status unavailable for ${providerId}, returning scoped error: ${
|
||||
`Provider-scoped summary runtime status unavailable for ${providerId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
if (
|
||||
this.isRuntimeStatusTimeoutError(error) &&
|
||||
this.shouldUseLegacyProviderTimeoutFallback(providerId)
|
||||
) {
|
||||
logger.warn(
|
||||
`Provider-scoped summary runtime status timed out for ${providerId}, falling back to scoped legacy probes: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, error);
|
||||
}
|
||||
return createRuntimeStatusErrorProviderStatus(providerId, error);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export function splitSettingsJsonArgs(args: string[]): SplitSettingsJsonArgsResu
|
|||
const parsed = parseJsonSettingsObject(value);
|
||||
if (parsed) {
|
||||
settingsFragments.push(parsed);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,26 @@
|
|||
import { isMixedOpenCodeSideLanePlan, planTeamRuntimeLanes } from '@features/team-runtime-lanes';
|
||||
import {
|
||||
hasBootstrapConfirmationProofForLaunchFailure,
|
||||
hasUnsafeProvisionedButNotAliveRuntimeEvidence,
|
||||
isProvisionedButNotAliveLaunchFailure,
|
||||
} from '@shared/utils/teamLaunchFailureReason';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import { isBootstrapMemberEvidenceCurrentForMember } from './provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy';
|
||||
import { shouldIgnoreTerminalBootstrapOnlyPendingSnapshot } from './TeamBootstrapStateReader';
|
||||
import { hasMixedPersistedLaunchMetadata } from './TeamLaunchStateEvaluator';
|
||||
import {
|
||||
deriveTeamLaunchAggregateState,
|
||||
hasMixedPersistedLaunchMetadata,
|
||||
summarizePersistedLaunchMembers,
|
||||
} from './TeamLaunchStateEvaluator';
|
||||
|
||||
import type { PersistedTeamLaunchSnapshot, TeamProviderId, TeamSummary } from '@shared/types';
|
||||
import type {
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
PersistedTeamLaunchSummary,
|
||||
TeamProviderId,
|
||||
TeamSummary,
|
||||
} from '@shared/types';
|
||||
|
||||
export const TEAM_LAUNCH_SUMMARY_FILE = 'launch-summary.json';
|
||||
const STALE_PENDING_SUMMARY_GRACE_MS = 5 * 60 * 1000;
|
||||
|
|
@ -41,6 +57,71 @@ function getPersistedLaunchMemberNames(snapshot: PersistedTeamLaunchSnapshot): s
|
|||
return Array.from(new Set([...snapshot.expectedMembers, ...Object.keys(snapshot.members)]));
|
||||
}
|
||||
|
||||
function hasBootstrapConfirmationProof(
|
||||
member: PersistedTeamLaunchMemberState,
|
||||
bootstrapMember: PersistedTeamLaunchMemberState | undefined
|
||||
): boolean {
|
||||
if (hasBootstrapConfirmationProofForLaunchFailure(member)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
bootstrapMember != null &&
|
||||
hasBootstrapConfirmationProofForLaunchFailure(bootstrapMember) &&
|
||||
isBootstrapMemberEvidenceCurrentForMember(member, bootstrapMember, 'confirmation')
|
||||
);
|
||||
}
|
||||
|
||||
function shouldProjectProvisionedButNotAliveAsConfirmed(params: {
|
||||
member: PersistedTeamLaunchMemberState | undefined;
|
||||
bootstrapMember?: PersistedTeamLaunchMemberState;
|
||||
}): params is { member: PersistedTeamLaunchMemberState } {
|
||||
const member = params.member;
|
||||
if (member?.launchState !== 'failed_to_start' || member.hardFailure !== true) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
hasUnsafeProvisionedButNotAliveRuntimeEvidence(member) ||
|
||||
hasUnsafeProvisionedButNotAliveRuntimeEvidence(params.bootstrapMember)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isProvisionedButNotAliveLaunchFailure(member) &&
|
||||
hasBootstrapConfirmationProof(member, params.bootstrapMember)
|
||||
);
|
||||
}
|
||||
|
||||
function buildProjectedMembersForSummary(
|
||||
snapshot: PersistedTeamLaunchSnapshot,
|
||||
bootstrapSnapshot?: PersistedTeamLaunchSnapshot | null
|
||||
): Record<string, PersistedTeamLaunchMemberState> | null {
|
||||
let changed = false;
|
||||
const projectedMembers: Record<string, PersistedTeamLaunchMemberState> = {};
|
||||
for (const [memberName, member] of Object.entries(snapshot.members)) {
|
||||
if (
|
||||
shouldProjectProvisionedButNotAliveAsConfirmed({
|
||||
member,
|
||||
bootstrapMember: bootstrapSnapshot?.members[memberName],
|
||||
})
|
||||
) {
|
||||
changed = true;
|
||||
projectedMembers[memberName] = {
|
||||
...member,
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
runtimeDiagnostic: undefined,
|
||||
runtimeDiagnosticSeverity: undefined,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
projectedMembers[memberName] = member;
|
||||
}
|
||||
return changed ? projectedMembers : null;
|
||||
}
|
||||
|
||||
function normalizeIsoDate(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
|
|
@ -57,42 +138,47 @@ function toMillis(value: string | undefined | null): number {
|
|||
}
|
||||
|
||||
export function createLaunchStateSummary(
|
||||
snapshot: PersistedTeamLaunchSnapshot
|
||||
snapshot: PersistedTeamLaunchSnapshot,
|
||||
options: { bootstrapSnapshot?: PersistedTeamLaunchSnapshot | null } = {}
|
||||
): LaunchStateSummary {
|
||||
const persistedMemberNames = getPersistedLaunchMemberNames(snapshot);
|
||||
const projectedMembers = buildProjectedMembersForSummary(snapshot, options.bootstrapSnapshot);
|
||||
const members = projectedMembers ?? snapshot.members;
|
||||
const summary = projectedMembers
|
||||
? summarizePersistedLaunchMembers(snapshot.expectedMembers, projectedMembers)
|
||||
: snapshot.summary;
|
||||
const teamLaunchState = projectedMembers
|
||||
? deriveTeamLaunchAggregateState(summary)
|
||||
: snapshot.teamLaunchState;
|
||||
const missingMembers = persistedMemberNames.filter((name) => {
|
||||
const member = snapshot.members[name];
|
||||
const member = members[name];
|
||||
return member?.launchState === 'failed_to_start';
|
||||
});
|
||||
const skippedMembers = persistedMemberNames.filter((name) => {
|
||||
const member = snapshot.members[name];
|
||||
const member = members[name];
|
||||
return member?.launchState === 'skipped_for_launch' || member?.skippedForLaunch === true;
|
||||
});
|
||||
|
||||
return {
|
||||
...(snapshot.teamLaunchState === 'partial_failure'
|
||||
? { partialLaunchFailure: true as const }
|
||||
: {}),
|
||||
...(teamLaunchState === 'partial_failure' ? { partialLaunchFailure: true as const } : {}),
|
||||
...(persistedMemberNames.length > 0
|
||||
? { expectedMemberCount: persistedMemberNames.length }
|
||||
: {}),
|
||||
...(snapshot.summary.confirmedCount > 0
|
||||
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
||||
: {}),
|
||||
...(summary.confirmedCount > 0 ? { confirmedMemberCount: summary.confirmedCount } : {}),
|
||||
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
||||
...(skippedMembers.length > 0 ? { skippedMembers } : {}),
|
||||
teamLaunchState: snapshot.teamLaunchState,
|
||||
teamLaunchState,
|
||||
launchUpdatedAt: snapshot.updatedAt,
|
||||
confirmedCount: snapshot.summary.confirmedCount,
|
||||
pendingCount: snapshot.summary.pendingCount,
|
||||
failedCount: snapshot.summary.failedCount,
|
||||
skippedCount: snapshot.summary.skippedCount,
|
||||
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
|
||||
shellOnlyPendingCount: snapshot.summary.shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount: snapshot.summary.runtimeProcessPendingCount,
|
||||
runtimeCandidatePendingCount: snapshot.summary.runtimeCandidatePendingCount,
|
||||
noRuntimePendingCount: snapshot.summary.noRuntimePendingCount,
|
||||
permissionPendingCount: snapshot.summary.permissionPendingCount,
|
||||
confirmedCount: summary.confirmedCount,
|
||||
pendingCount: summary.pendingCount,
|
||||
failedCount: summary.failedCount,
|
||||
skippedCount: summary.skippedCount,
|
||||
runtimeAlivePendingCount: summary.runtimeAlivePendingCount,
|
||||
shellOnlyPendingCount: summary.shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount: summary.runtimeProcessPendingCount,
|
||||
runtimeCandidatePendingCount: summary.runtimeCandidatePendingCount,
|
||||
noRuntimePendingCount: summary.noRuntimePendingCount,
|
||||
permissionPendingCount: summary.permissionPendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -242,6 +328,83 @@ function shouldIgnoreStalePendingLaunchSnapshotSummary(
|
|||
return Number.isFinite(updatedAtMs) && nowMs - updatedAtMs >= STALE_PENDING_SUMMARY_GRACE_MS;
|
||||
}
|
||||
|
||||
function reconcileSummaryProjectionWithBootstrap(
|
||||
projection: PersistedTeamLaunchSummaryProjection,
|
||||
bootstrapSnapshot: PersistedTeamLaunchSnapshot
|
||||
): PersistedTeamLaunchSummaryProjection {
|
||||
const missingMembers = projection.missingMembers ?? [];
|
||||
if (missingMembers.length === 0) {
|
||||
return projection;
|
||||
}
|
||||
|
||||
const projectionBoundary = projection.launchUpdatedAt ?? projection.updatedAt;
|
||||
const healedMembers = missingMembers.filter((memberName) => {
|
||||
const bootstrapMember = bootstrapSnapshot.members[memberName];
|
||||
return (
|
||||
bootstrapMember != null &&
|
||||
hasBootstrapConfirmationProofForLaunchFailure(bootstrapMember) &&
|
||||
!hasUnsafeProvisionedButNotAliveRuntimeEvidence(bootstrapMember) &&
|
||||
isBootstrapMemberEvidenceCurrentForMember(
|
||||
{ firstSpawnAcceptedAt: projectionBoundary, lastEvaluatedAt: projectionBoundary },
|
||||
bootstrapMember,
|
||||
'confirmation'
|
||||
)
|
||||
);
|
||||
});
|
||||
if (healedMembers.length === 0) {
|
||||
return projection;
|
||||
}
|
||||
|
||||
const healedMemberNames = new Set(healedMembers);
|
||||
const nextMissingMembers = missingMembers.filter(
|
||||
(memberName) => !healedMemberNames.has(memberName)
|
||||
);
|
||||
const summary: PersistedTeamLaunchSummary = {
|
||||
confirmedCount:
|
||||
(projection.confirmedCount ?? projection.confirmedMemberCount ?? 0) + healedMembers.length,
|
||||
pendingCount: projection.pendingCount ?? 0,
|
||||
failedCount: Math.max(
|
||||
0,
|
||||
(projection.failedCount ?? missingMembers.length) - healedMembers.length
|
||||
),
|
||||
skippedCount: projection.skippedCount ?? projection.skippedMembers?.length ?? 0,
|
||||
runtimeAlivePendingCount: projection.runtimeAlivePendingCount ?? 0,
|
||||
shellOnlyPendingCount: projection.shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount: projection.runtimeProcessPendingCount,
|
||||
runtimeCandidatePendingCount: projection.runtimeCandidatePendingCount,
|
||||
noRuntimePendingCount: projection.noRuntimePendingCount,
|
||||
permissionPendingCount: projection.permissionPendingCount,
|
||||
};
|
||||
const teamLaunchState = deriveTeamLaunchAggregateState(summary);
|
||||
|
||||
const reconciled: PersistedTeamLaunchSummaryProjection = {
|
||||
...projection,
|
||||
teamLaunchState,
|
||||
confirmedMemberCount: summary.confirmedCount,
|
||||
confirmedCount: summary.confirmedCount,
|
||||
pendingCount: summary.pendingCount,
|
||||
failedCount: summary.failedCount,
|
||||
skippedCount: summary.skippedCount,
|
||||
runtimeAlivePendingCount: summary.runtimeAlivePendingCount,
|
||||
shellOnlyPendingCount: summary.shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount: summary.runtimeProcessPendingCount,
|
||||
runtimeCandidatePendingCount: summary.runtimeCandidatePendingCount,
|
||||
noRuntimePendingCount: summary.noRuntimePendingCount,
|
||||
permissionPendingCount: summary.permissionPendingCount,
|
||||
};
|
||||
if (nextMissingMembers.length > 0) {
|
||||
reconciled.missingMembers = nextMissingMembers;
|
||||
} else {
|
||||
delete reconciled.missingMembers;
|
||||
}
|
||||
if (teamLaunchState === 'partial_failure') {
|
||||
reconciled.partialLaunchFailure = true;
|
||||
} else {
|
||||
delete reconciled.partialLaunchFailure;
|
||||
}
|
||||
return reconciled;
|
||||
}
|
||||
|
||||
export function choosePreferredLaunchStateSummary(params: {
|
||||
bootstrapSnapshot?: PersistedTeamLaunchSnapshot | null;
|
||||
launchSnapshot?: PersistedTeamLaunchSnapshot | null;
|
||||
|
|
@ -252,7 +415,9 @@ export function choosePreferredLaunchStateSummary(params: {
|
|||
? null
|
||||
: (params.launchSnapshot ?? null);
|
||||
if (launchSnapshot) {
|
||||
return createLaunchStateSummary(launchSnapshot);
|
||||
return createLaunchStateSummary(launchSnapshot, {
|
||||
bootstrapSnapshot: params.bootstrapSnapshot ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const bootstrapSnapshot = params.bootstrapSnapshot ?? null;
|
||||
|
|
@ -271,22 +436,28 @@ export function choosePreferredLaunchStateSummary(params: {
|
|||
return createLaunchStateSummary(bootstrapSnapshot);
|
||||
}
|
||||
|
||||
const reconciledProjection = reconcileSummaryProjectionWithBootstrap(
|
||||
projection,
|
||||
bootstrapSnapshot
|
||||
);
|
||||
const bootstrapMixedAware = hasMixedPersistedLaunchMetadata(bootstrapSnapshot);
|
||||
const projectionMixedAware = projection.mixedAware === true;
|
||||
const projectionMixedAware = reconciledProjection.mixedAware === true;
|
||||
if (projectionMixedAware !== bootstrapMixedAware) {
|
||||
return projectionMixedAware ? projection : createLaunchStateSummary(bootstrapSnapshot);
|
||||
return projectionMixedAware
|
||||
? reconciledProjection
|
||||
: createLaunchStateSummary(bootstrapSnapshot);
|
||||
}
|
||||
|
||||
const projectionUpdatedAtMs = toMillis(projection.updatedAt);
|
||||
const projectionUpdatedAtMs = toMillis(reconciledProjection.updatedAt);
|
||||
const bootstrapUpdatedAtMs = toMillis(bootstrapSnapshot.updatedAt);
|
||||
if (!Number.isFinite(bootstrapUpdatedAtMs)) {
|
||||
return projection;
|
||||
return reconciledProjection;
|
||||
}
|
||||
if (!Number.isFinite(projectionUpdatedAtMs)) {
|
||||
return createLaunchStateSummary(bootstrapSnapshot);
|
||||
}
|
||||
return projectionUpdatedAtMs >= bootstrapUpdatedAtMs
|
||||
? projection
|
||||
? reconciledProjection
|
||||
: createLaunchStateSummary(bootstrapSnapshot);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
|
|||
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
|
||||
const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
|
||||
const ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
|
||||
const MIN_MCP_NODE_MAJOR_VERSION = 20;
|
||||
// The packaged Electron runtime can lag the source toolchain patch version,
|
||||
// so MCP launch validation pins the Node 24 runtime line, not .node-version.
|
||||
const MIN_MCP_NODE_MAJOR_VERSION = 24;
|
||||
const MAX_MCP_NODE_MAJOR_VERSION = 25;
|
||||
const NODE_RUNTIME_PROBE_SCRIPT =
|
||||
'process.stdout.write(JSON.stringify({execPath:process.execPath,version:process.versions.node}))';
|
||||
/**
|
||||
|
|
@ -335,9 +338,9 @@ function parseNodeRuntimeProbeMetadata(stdout: string, command: string): NodeRun
|
|||
|
||||
function assertSupportedMcpNodeRuntime(command: string, metadata: NodeRuntimeProbeMetadata): void {
|
||||
const major = parseNodeMajorVersion(metadata.version);
|
||||
if (major === null || major < MIN_MCP_NODE_MAJOR_VERSION) {
|
||||
if (major === null || major < MIN_MCP_NODE_MAJOR_VERSION || major >= MAX_MCP_NODE_MAJOR_VERSION) {
|
||||
throw new Error(
|
||||
`${command} resolved ${metadata.path} with Node.js ${metadata.version}; Agent Teams MCP requires Node.js ${MIN_MCP_NODE_MAJOR_VERSION}+`
|
||||
`${command} resolved ${metadata.path} with Node.js ${metadata.version}; Agent Teams MCP requires Node.js 24.x`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -392,21 +395,16 @@ async function probePackagedElectronNodeRuntime(
|
|||
|
||||
emitProgress(options, 'electron-node-runtime', 'Checking bundled Electron Node runtime...');
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
process.execPath.trim(),
|
||||
['-e', 'process.stdout.write("agent-teams-electron-node-ok")'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS,
|
||||
env: {
|
||||
...process.env,
|
||||
...getPackagedElectronNodeEnv(),
|
||||
},
|
||||
}
|
||||
);
|
||||
if (stdout.trim() !== 'agent-teams-electron-node-ok') {
|
||||
throw new Error('Electron Node runtime probe did not return the expected marker');
|
||||
}
|
||||
const { stdout } = await execCli(process.execPath.trim(), ['-e', NODE_RUNTIME_PROBE_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
timeout: ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS,
|
||||
env: {
|
||||
...process.env,
|
||||
...getPackagedElectronNodeEnv(),
|
||||
},
|
||||
});
|
||||
const metadata = parseNodeRuntimeProbeMetadata(stdout, process.execPath.trim());
|
||||
assertSupportedMcpNodeRuntime(process.execPath.trim(), metadata);
|
||||
_packagedElectronNodeRuntimeProbe = { ok: true };
|
||||
} catch (error) {
|
||||
_packagedElectronNodeRuntimeProbe = { ok: false, error };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
||||
import {
|
||||
isDisplayableTeammateProtocol,
|
||||
isHumanAuthoredUserTurn,
|
||||
} from '@shared/utils/userTurnProvenance';
|
||||
import { createReadStream } from 'fs';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
|
@ -1831,16 +1835,17 @@ export class TeamMemberLogsFinder {
|
|||
try {
|
||||
const msg = JSON.parse(line) as Record<string, unknown>;
|
||||
|
||||
const role = this.extractRole(msg);
|
||||
const textContent = this.extractTextContent(msg);
|
||||
|
||||
const isAuthoredUserText = this.isAuthoredUserTextEntry(msg);
|
||||
|
||||
// Skip warmup messages
|
||||
if (role === 'user' && textContent?.trim() === 'Warmup') {
|
||||
if (isAuthoredUserText && textContent?.trim() === 'Warmup') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract description from first user message + collect teammate_id signal
|
||||
if (role === 'user' && textContent) {
|
||||
if (isAuthoredUserText && textContent) {
|
||||
if (textContent.trimStart().startsWith('<teammate-message')) {
|
||||
const parsed = parseAllTeammateMessages(textContent);
|
||||
if (!description) {
|
||||
|
|
@ -1862,7 +1867,9 @@ export class TeamMemberLogsFinder {
|
|||
}
|
||||
|
||||
// Collect text_mention signal (lowest reliability — exact one member name in text)
|
||||
const textMention = this.detectMemberFromMessage(msg, knownMembers);
|
||||
const textMention = isAuthoredUserText
|
||||
? this.detectMemberFromMessage(msg, knownMembers)
|
||||
: null;
|
||||
if (textMention) {
|
||||
signals.push({ member: textMention.name, source: 'text_mention' });
|
||||
}
|
||||
|
|
@ -1986,10 +1993,10 @@ export class TeamMemberLogsFinder {
|
|||
}
|
||||
}
|
||||
|
||||
const role = this.extractRole(entry);
|
||||
const textContent = this.extractTextContent(entry);
|
||||
const isAuthoredUserText = this.isAuthoredUserTextEntry(entry);
|
||||
const lowerTextContent = textContent?.toLowerCase();
|
||||
if (!teamMatched && lowerTextContent?.includes(normalizedTeam)) {
|
||||
if (!teamMatched && isAuthoredUserText && lowerTextContent?.includes(normalizedTeam)) {
|
||||
if (
|
||||
lowerTextContent.includes(`on team "${normalizedTeam}"`) ||
|
||||
lowerTextContent.includes(`on team '${normalizedTeam}'`) ||
|
||||
|
|
@ -1999,7 +2006,7 @@ export class TeamMemberLogsFinder {
|
|||
}
|
||||
}
|
||||
|
||||
if (role === 'user' && textContent && !description) {
|
||||
if (isAuthoredUserText && textContent && !description) {
|
||||
const normalizedText = textContent.trim();
|
||||
if (
|
||||
normalizedText.length > 0 &&
|
||||
|
|
@ -2052,7 +2059,7 @@ export class TeamMemberLogsFinder {
|
|||
msg: Record<string, unknown>,
|
||||
knownMembers: Set<string>
|
||||
): { name: string; priority: number } | null {
|
||||
if (this.extractRole(msg) !== 'user') return null;
|
||||
if (!this.isAuthoredUserTextEntry(msg)) return null;
|
||||
|
||||
const text = this.extractTextContent(msg);
|
||||
if (!text) return null;
|
||||
|
|
@ -2073,6 +2080,11 @@ export class TeamMemberLogsFinder {
|
|||
return null;
|
||||
}
|
||||
|
||||
private isAuthoredUserTextEntry(msg: Record<string, unknown>): boolean {
|
||||
if (this.extractRole(msg) !== 'user') return false;
|
||||
return isHumanAuthoredUserTurn(msg) || isDisplayableTeammateProtocol(msg);
|
||||
}
|
||||
|
||||
private extractTextContent(msg: Record<string, unknown>): string | null {
|
||||
if (typeof msg.content === 'string') {
|
||||
return msg.content;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -64,6 +64,7 @@ export interface RuntimeToolApprovalSyncScope {
|
|||
teamName: string;
|
||||
runId: string;
|
||||
laneId?: string;
|
||||
memberNames?: readonly string[];
|
||||
providerId?: RuntimeApprovalProviderId;
|
||||
}
|
||||
|
||||
|
|
@ -405,6 +406,9 @@ export class RuntimeToolApprovalCoordinator {
|
|||
if (scope.laneId && entry.laneId !== scope.laneId) {
|
||||
return false;
|
||||
}
|
||||
if (scope.memberNames?.length && !scope.memberNames.includes(entry.memberName)) {
|
||||
return false;
|
||||
}
|
||||
if (scope.providerId && entry.providerId !== scope.providerId) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,11 +160,14 @@ export interface OpenCodeListRuntimePermissionsCommandBody {
|
|||
teamId: string;
|
||||
teamName: string;
|
||||
laneId?: string;
|
||||
memberName?: string;
|
||||
sessionId?: string | null;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeListRuntimePermissionsCommandData {
|
||||
permissions: OpenCodeRuntimePermissionCommandData[];
|
||||
diagnostics?: string[];
|
||||
}
|
||||
|
||||
export interface OpenCodeCleanupHostsCommandBody {
|
||||
|
|
|
|||
|
|
@ -257,7 +257,13 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
if (result.ok) {
|
||||
return result.data;
|
||||
}
|
||||
return { permissions: [] };
|
||||
return {
|
||||
permissions: [],
|
||||
diagnostics: [
|
||||
`OpenCode runtime permission list bridge failed: ${result.error.kind}: ${result.error.message}`,
|
||||
...result.diagnostics.map(formatDiagnosticEvent),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async cleanupOpenCodeHosts(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { TeamProviderId } from '@shared/types';
|
|||
const DIRECT_TMUX_RESTART_ENV_KEYS = [
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
'CLAUDE_TEAM_CONTROL_URL',
|
||||
'CLAUDE_TEAM_RUNTIME_SETTINGS_PATH',
|
||||
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
|
|
@ -21,6 +22,8 @@ const DIRECT_TMUX_RESTART_ENV_KEYS = [
|
|||
'CLAUDE_CODE_ENTRY_PROVIDER',
|
||||
'CLAUDE_CODE_GEMINI_BACKEND',
|
||||
'CLAUDE_CODE_CODEX_BACKEND',
|
||||
'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD',
|
||||
'CODEX_CLI_PATH',
|
||||
'CODEX_HOME',
|
||||
CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV,
|
||||
CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import {
|
||||
hasUnsafeProvisionedButNotAliveRuntimeEvidence,
|
||||
isBootstrapConfirmedProvisionedButNotAliveFailure,
|
||||
mentionsProcessTableUnavailable,
|
||||
} from '@shared/utils/teamLaunchFailureReason';
|
||||
|
||||
import type { WorkspaceTrustExecutionResult } from '@features/workspace-trust/main';
|
||||
import type { MemberSpawnStatusEntry, TeamLaunchDiagnosticItem } from '@shared/types';
|
||||
|
||||
export { mentionsProcessTableUnavailable };
|
||||
|
||||
export interface TeamProvisioningLaunchDiagnosticsRun {
|
||||
isLaunch: boolean;
|
||||
memberSpawnStatuses?: ReadonlyMap<string, MemberSpawnStatusEntry> | null;
|
||||
|
|
@ -12,10 +20,6 @@ interface LaunchDiagnosticsClockOptions {
|
|||
|
||||
const defaultNowIso = (): string => new Date().toISOString();
|
||||
|
||||
export function mentionsProcessTableUnavailable(value: string | undefined): boolean {
|
||||
return /\bprocess table\b.*\bunavailable\b/i.test(value ?? '');
|
||||
}
|
||||
|
||||
export function buildLaunchDiagnosticsFromRun(
|
||||
run: TeamProvisioningLaunchDiagnosticsRun,
|
||||
options: LaunchDiagnosticsClockOptions = {}
|
||||
|
|
@ -28,7 +32,24 @@ export function buildLaunchDiagnosticsFromRun(
|
|||
const observedAt = (options.nowIso ?? defaultNowIso)();
|
||||
const items: TeamLaunchDiagnosticItem[] = [];
|
||||
for (const [memberName, entry] of memberSpawnStatuses.entries()) {
|
||||
if (entry.launchState === 'confirmed_alive') {
|
||||
const bootstrapConfirmedProvisionedButNotAlive =
|
||||
isBootstrapConfirmedProvisionedButNotAliveFailure(entry);
|
||||
if (
|
||||
bootstrapConfirmedProvisionedButNotAlive &&
|
||||
hasUnsafeProvisionedButNotAliveRuntimeEvidence(entry)
|
||||
) {
|
||||
items.push({
|
||||
id: `${memberName}:bootstrap_stalled`,
|
||||
memberName,
|
||||
severity: 'error',
|
||||
code: 'bootstrap_stalled',
|
||||
label: `${memberName} - launch diagnostic error`,
|
||||
detail: entry.runtimeDiagnostic ?? entry.hardFailureReason ?? entry.error,
|
||||
observedAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'confirmed_alive' || bootstrapConfirmedProvisionedButNotAlive) {
|
||||
items.push({
|
||||
id: `${memberName}:bootstrap_confirmed`,
|
||||
memberName,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import {
|
||||
isProvisionedButNotAliveFailureReason,
|
||||
stripProcessTableUnavailableDiagnosticSuffix,
|
||||
} from '@shared/utils/teamLaunchFailureReason';
|
||||
|
||||
import { mentionsProcessTableUnavailable } from './TeamProvisioningLaunchDiagnostics';
|
||||
import { isBootstrapInstructionPrompt } from './TeamProvisioningPromptBuilders';
|
||||
|
||||
export {
|
||||
isCliProvisionedButNotAliveFailureReason,
|
||||
isProvisionedButNotAliveFailureReason,
|
||||
stripProcessTableUnavailableDiagnosticSuffix,
|
||||
} from '@shared/utils/teamLaunchFailureReason';
|
||||
|
||||
import type { MemberLaunchState } from '@shared/types';
|
||||
|
||||
export function isNeverSpawnedDuringLaunchReason(reason?: string): boolean {
|
||||
|
|
@ -37,12 +48,6 @@ export function isProcessTableUnavailableFailureReason(reason?: string): boolean
|
|||
);
|
||||
}
|
||||
|
||||
export function stripProcessTableUnavailableDiagnosticSuffix(reason: string): string | null {
|
||||
const match = /^(.*?);\s*process table (?:is )?unavailable$/i.exec(reason.trim());
|
||||
const baseReason = match?.[1]?.trim();
|
||||
return baseReason && baseReason.length > 0 ? baseReason : null;
|
||||
}
|
||||
|
||||
function isBaseAutoClearableLaunchFailureReason(reason?: string): boolean {
|
||||
return (
|
||||
isNeverSpawnedDuringLaunchReason(reason) ||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { resolveTeamProviderId } from '@main/services/runtime/providerRuntimeEnv
|
|||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, wrapAgentBlock } from '@shared/constants/agentBlocks';
|
||||
import { CROSS_TEAM_PREFIX_TAG } from '@shared/constants/crossTeam';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
hasUnsafeProvisionedButNotAliveRuntimeEvidence,
|
||||
isBootstrapConfirmedProvisionedButNotAliveFailure,
|
||||
} from '@shared/utils/teamLaunchFailureReason';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskActivelyWorked,
|
||||
|
|
@ -44,6 +48,57 @@ interface CanonicalSendMessageExample {
|
|||
const SEND_MESSAGE_CANONICAL_FIELDS = ['to', 'summary', 'message'] as const;
|
||||
const SEND_MESSAGE_FORBIDDEN_ALIAS_FIELDS = ['recipient', 'content'] as const;
|
||||
|
||||
function isUnsafeProvisionedButNotAliveStatus(status: MemberSpawnStatusEntry | undefined) {
|
||||
return (
|
||||
isBootstrapConfirmedProvisionedButNotAliveFailure(status) &&
|
||||
hasUnsafeProvisionedButNotAliveRuntimeEvidence(status)
|
||||
);
|
||||
}
|
||||
|
||||
function isSafelyHealedProvisionedButNotAliveStatus(status: MemberSpawnStatusEntry | undefined) {
|
||||
return (
|
||||
isBootstrapConfirmedProvisionedButNotAliveFailure(status) &&
|
||||
!isUnsafeProvisionedButNotAliveStatus(status)
|
||||
);
|
||||
}
|
||||
|
||||
function formatFailedLaunchStatus(status: MemberSpawnStatusEntry): string {
|
||||
return `failed to start${status.hardFailureReason ? ` - ${status.hardFailureReason}` : status.error ? ` - ${status.error}` : ''}`;
|
||||
}
|
||||
|
||||
function buildTeammateLaunchStatusLabel(status: MemberSpawnStatusEntry | undefined): string {
|
||||
if (!status) {
|
||||
return 'runtime state unclear';
|
||||
}
|
||||
if (
|
||||
status.launchState === 'failed_to_start' &&
|
||||
!isSafelyHealedProvisionedButNotAliveStatus(status)
|
||||
) {
|
||||
return formatFailedLaunchStatus(status);
|
||||
}
|
||||
if (
|
||||
status.launchState === 'confirmed_alive' ||
|
||||
isSafelyHealedProvisionedButNotAliveStatus(status)
|
||||
) {
|
||||
return 'bootstrap confirmed';
|
||||
}
|
||||
if (status.launchState === 'runtime_pending_permission') {
|
||||
return status.runtimeAlive
|
||||
? 'runtime online and waiting for permission approval'
|
||||
: 'waiting for permission approval';
|
||||
}
|
||||
if (status.runtimeAlive) {
|
||||
return 'runtime online and ready for instructions';
|
||||
}
|
||||
if (status.launchState === 'runtime_pending_bootstrap') {
|
||||
return 'spawn accepted, runtime not confirmed yet';
|
||||
}
|
||||
if (status.status === 'spawning') {
|
||||
return 'spawn in progress';
|
||||
}
|
||||
return 'runtime state unclear';
|
||||
}
|
||||
|
||||
export function buildCanonicalSendMessageExample(example: CanonicalSendMessageExample): string {
|
||||
return `{ ${SEND_MESSAGE_CANONICAL_FIELDS.map((field) => `${field}: "${example[field]}"`).join(', ')} }`;
|
||||
}
|
||||
|
|
@ -1037,22 +1092,7 @@ export function buildGeminiPostLaunchHydrationPrompt(
|
|||
? `Current teammate launch status:\n${members
|
||||
.map((member) => {
|
||||
const status = run.memberSpawnStatuses.get(member.name);
|
||||
const label =
|
||||
status?.launchState === 'failed_to_start'
|
||||
? `failed to start${status.hardFailureReason ? ` - ${status.hardFailureReason}` : status.error ? ` - ${status.error}` : ''}`
|
||||
: status?.launchState === 'confirmed_alive'
|
||||
? 'bootstrap confirmed'
|
||||
: status?.launchState === 'runtime_pending_permission'
|
||||
? status?.runtimeAlive
|
||||
? 'runtime online and waiting for permission approval'
|
||||
: 'waiting for permission approval'
|
||||
: status?.runtimeAlive
|
||||
? 'runtime online and ready for instructions'
|
||||
: status?.launchState === 'runtime_pending_bootstrap'
|
||||
? 'spawn accepted, runtime not confirmed yet'
|
||||
: status?.status === 'spawning'
|
||||
? 'spawn in progress'
|
||||
: 'runtime state unclear';
|
||||
const label = buildTeammateLaunchStatusLabel(status);
|
||||
return `- @${member.name}: ${label}`;
|
||||
})
|
||||
.join('\n')}\n`
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import type {
|
|||
OpenCodeBridgeRuntimeSnapshot,
|
||||
OpenCodeLaunchTeamCommandBody,
|
||||
OpenCodeLaunchTeamCommandData,
|
||||
OpenCodeListRuntimePermissionsCommandBody,
|
||||
OpenCodeListRuntimePermissionsCommandData,
|
||||
OpenCodeObserveMessageDeliveryCommandBody,
|
||||
OpenCodeObserveMessageDeliveryCommandData,
|
||||
OpenCodeReconcileTeamCommandBody,
|
||||
|
|
@ -24,6 +26,8 @@ import type {
|
|||
TeamRuntimeMemberStopEvidence,
|
||||
TeamRuntimePendingPermission,
|
||||
TeamRuntimePermissionAnswerInput,
|
||||
TeamRuntimePermissionListInput,
|
||||
TeamRuntimePermissionListResult,
|
||||
TeamRuntimePrepareResult,
|
||||
TeamRuntimeReconcileInput,
|
||||
TeamRuntimeReconcileResult,
|
||||
|
|
@ -59,6 +63,9 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
|||
answerOpenCodeRuntimePermission?(
|
||||
input: OpenCodeAnswerPermissionCommandBody
|
||||
): Promise<OpenCodeLaunchTeamCommandData>;
|
||||
listOpenCodeRuntimePermissions?(
|
||||
input: OpenCodeListRuntimePermissionsCommandBody
|
||||
): Promise<OpenCodeListRuntimePermissionsCommandData>;
|
||||
}
|
||||
|
||||
export interface OpenCodeTeamRuntimeMessageInput {
|
||||
|
|
@ -599,6 +606,30 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
);
|
||||
}
|
||||
|
||||
async listRuntimePermissions(
|
||||
input: TeamRuntimePermissionListInput
|
||||
): Promise<TeamRuntimePermissionListResult> {
|
||||
if (!this.bridge.listOpenCodeRuntimePermissions) {
|
||||
return {
|
||||
permissions: [],
|
||||
diagnostics: ['OpenCode runtime permission list bridge is not registered.'],
|
||||
};
|
||||
}
|
||||
|
||||
const data = await this.bridge.listOpenCodeRuntimePermissions({
|
||||
teamId: input.teamName,
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
memberName: input.memberName,
|
||||
sessionId: input.sessionId,
|
||||
projectPath: input.cwd,
|
||||
});
|
||||
return {
|
||||
permissions: normalizeOpenCodeRuntimePendingPermissions(data.permissions) ?? [],
|
||||
diagnostics: data.diagnostics ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult> {
|
||||
if (this.bridge.stopOpenCodeTeam) {
|
||||
const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,19 @@ export interface TeamRuntimePermissionAnswerInput {
|
|||
previousLaunchState: PersistedTeamLaunchSnapshot | null;
|
||||
}
|
||||
|
||||
export interface TeamRuntimePermissionListInput {
|
||||
teamName: string;
|
||||
laneId?: string;
|
||||
cwd?: string;
|
||||
memberName?: string;
|
||||
sessionId?: string | null;
|
||||
}
|
||||
|
||||
export interface TeamRuntimePermissionListResult {
|
||||
permissions: TeamRuntimePendingPermission[];
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export interface TeamRuntimeLaunchInput {
|
||||
runId: string;
|
||||
teamName: string;
|
||||
|
|
@ -206,6 +219,9 @@ export interface TeamLaunchRuntimeAdapter {
|
|||
answerRuntimePermission?(
|
||||
input: TeamRuntimePermissionAnswerInput
|
||||
): Promise<TeamRuntimeLaunchResult>;
|
||||
listRuntimePermissions?(
|
||||
input: TeamRuntimePermissionListInput
|
||||
): Promise<TeamRuntimePermissionListResult>;
|
||||
}
|
||||
|
||||
export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export type {
|
|||
TeamRuntimeMemberStopEvidence,
|
||||
TeamRuntimePendingApproval,
|
||||
TeamRuntimePendingPermission,
|
||||
TeamRuntimePermissionListInput,
|
||||
TeamRuntimePermissionListResult,
|
||||
TeamRuntimePrepareFailure,
|
||||
TeamRuntimePrepareResult,
|
||||
TeamRuntimePrepareSuccess,
|
||||
|
|
|
|||
|
|
@ -162,6 +162,10 @@ interface ConversationalEntry extends BaseEntry {
|
|||
*/
|
||||
export type ToolUseResultData = Record<string, unknown>;
|
||||
|
||||
export interface MessageOrigin {
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CRITICAL: User entries serve two purposes:
|
||||
*
|
||||
|
|
@ -182,6 +186,10 @@ export interface UserEntry extends ConversationalEntry {
|
|||
type: 'user';
|
||||
message: UserMessage;
|
||||
isMeta?: boolean;
|
||||
isSynthetic?: boolean;
|
||||
isReplay?: boolean;
|
||||
origin?: MessageOrigin;
|
||||
protocolKind?: string;
|
||||
agentId?: string;
|
||||
|
||||
toolUseResult?: ToolUseResultData;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@
|
|||
* parsed messages into categories for chunk building.
|
||||
*/
|
||||
|
||||
import {
|
||||
classifyUserTurnProvenance,
|
||||
isDisplayableTeammateProtocol,
|
||||
isHumanAuthoredUserTurn,
|
||||
isSyntheticReplayNoise,
|
||||
type MessageOriginLike,
|
||||
} from '@shared/utils/userTurnProvenance';
|
||||
|
||||
import {
|
||||
EMPTY_STDERR,
|
||||
EMPTY_STDOUT,
|
||||
|
|
@ -92,6 +100,14 @@ export interface ParsedMessage {
|
|||
isSidechain: boolean;
|
||||
/** Whether this is a meta message */
|
||||
isMeta: boolean;
|
||||
/** Whether this user-role row is a synthetic/replayed SDK event, not human-authored input */
|
||||
isSynthetic?: boolean;
|
||||
/** Whether this user-role row acknowledges a previously accepted turn */
|
||||
isReplay?: boolean;
|
||||
/** Structured source of a user-role row. Missing means legacy/human candidate. */
|
||||
origin?: MessageOriginLike;
|
||||
/** Structured protocol payload kind. Missing means legacy fallback. */
|
||||
protocolKind?: string;
|
||||
/** User type ("external" for user input) */
|
||||
userType?: string;
|
||||
// Extracted tool information
|
||||
|
|
@ -140,8 +156,7 @@ export interface ParsedMessage {
|
|||
* be treated as system responses, not user input that starts new chunks.
|
||||
*/
|
||||
export function isParsedRealUserMessage(msg: ParsedMessage): boolean {
|
||||
if (msg.type !== 'user') return false;
|
||||
if (msg.isMeta) return false;
|
||||
if (!isHumanAuthoredParsedUserMessage(msg)) return false;
|
||||
|
||||
const content = msg.content;
|
||||
|
||||
|
|
@ -180,9 +195,7 @@ export function isParsedRealUserMessage(msg: ParsedMessage): boolean {
|
|||
* - "<system-reminder>...</system-reminder>" -> Hard noise
|
||||
*/
|
||||
export function isParsedUserChunkMessage(msg: ParsedMessage): boolean {
|
||||
if (msg.type !== 'user') return false;
|
||||
if (msg.isMeta === true) return false;
|
||||
if (isParsedTeammateMessage(msg)) return false;
|
||||
if (!isHumanAuthoredParsedUserMessage(msg)) return false;
|
||||
|
||||
const content = msg.content;
|
||||
|
||||
|
|
@ -273,7 +286,10 @@ export function isParsedSystemChunkMessage(msg: ParsedMessage): boolean {
|
|||
// Array content - check text blocks
|
||||
if (Array.isArray(content)) {
|
||||
return content.some(
|
||||
(block) => block.type === 'text' && block.text.startsWith(LOCAL_COMMAND_STDOUT_TAG)
|
||||
(block) =>
|
||||
block.type === 'text' &&
|
||||
(block.text.startsWith(LOCAL_COMMAND_STDOUT_TAG) ||
|
||||
block.text.startsWith(LOCAL_COMMAND_STDERR_TAG))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -333,6 +349,24 @@ export function isParsedHardNoiseMessage(msg: ParsedMessage): boolean {
|
|||
if (msg.type === 'user') {
|
||||
const content = msg.content;
|
||||
|
||||
if (msg.isCompactSummary === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isSyntheticReplayNoise(msg)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const provenance = classifyUserTurnProvenance(msg);
|
||||
if (
|
||||
provenance !== 'human' &&
|
||||
provenance !== 'tool-result' &&
|
||||
provenance !== 'local-command-output' &&
|
||||
!isDisplayableTeammateProtocol(msg)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof content === 'string') {
|
||||
// Check if content contains ONLY noise tags (trim whitespace)
|
||||
const trimmedContent = content.trim();
|
||||
|
|
@ -404,20 +438,6 @@ export function isParsedCompactMessage(msg: ParsedMessage): boolean {
|
|||
return msg.isCompactSummary === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect teammate messages - messages from team member agents.
|
||||
* Format: <teammate-message teammate_id="name" ...>content</teammate-message>
|
||||
*/
|
||||
const TEAMMATE_MESSAGE_REGEX = /^<teammate-message\s+teammate_id="([^"]+)"/;
|
||||
|
||||
function isParsedTeammateMessage(msg: ParsedMessage): boolean {
|
||||
if (msg.type !== 'user' || msg.isMeta) return false;
|
||||
const content = msg.content;
|
||||
if (typeof content === 'string') return TEAMMATE_MESSAGE_REGEX.test(content.trim());
|
||||
if (Array.isArray(content)) {
|
||||
return content.some(
|
||||
(block) => block.type === 'text' && TEAMMATE_MESSAGE_REGEX.test(block.text.trim())
|
||||
);
|
||||
}
|
||||
return false;
|
||||
export function isHumanAuthoredParsedUserMessage(msg: ParsedMessage): boolean {
|
||||
return msg.type === 'user' && isHumanAuthoredUserTurn(msg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -341,6 +341,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
let codexNativeExecutableVersion: string | null | undefined;
|
||||
let isSidechain = false;
|
||||
let isMeta = false;
|
||||
let isSynthetic: boolean | undefined;
|
||||
let isReplay: boolean | undefined;
|
||||
let origin: { kind?: string } | undefined;
|
||||
let protocolKind: string | undefined;
|
||||
let userType: string | undefined;
|
||||
let sourceToolUseID: string | undefined;
|
||||
let sourceToolAssistantUUID: string | undefined;
|
||||
|
|
@ -364,7 +368,11 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
content = entry.message.content ?? '';
|
||||
role = entry.message.role;
|
||||
agentId = entry.agentId;
|
||||
isMeta = entry.isMeta ?? false;
|
||||
isSynthetic = entry.isSynthetic;
|
||||
isReplay = entry.isReplay;
|
||||
origin = entry.origin;
|
||||
protocolKind = entry.protocolKind;
|
||||
isMeta = (entry.isMeta ?? false) || entry.isSynthetic === true;
|
||||
sourceToolUseID = entry.sourceToolUseID;
|
||||
sourceToolAssistantUUID = entry.sourceToolAssistantUUID;
|
||||
toolUseResult = entry.toolUseResult;
|
||||
|
|
@ -415,6 +423,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
agentName,
|
||||
isSidechain,
|
||||
isMeta,
|
||||
isSynthetic,
|
||||
isReplay,
|
||||
origin,
|
||||
protocolKind,
|
||||
userType,
|
||||
isCompactSummary,
|
||||
level,
|
||||
|
|
@ -741,8 +753,8 @@ export async function analyzeSessionFileMetadata(
|
|||
model = parsed.model ?? model;
|
||||
}
|
||||
|
||||
if (!firstUserMessage && entry.type === 'user') {
|
||||
const content = entry.message?.content;
|
||||
if (!firstUserMessage && parsed.type === 'user' && isParsedUserChunkMessage(parsed)) {
|
||||
const content = parsed.content;
|
||||
if (typeof content === 'string') {
|
||||
if (isCommandOutputContent(content)) {
|
||||
// Skip
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isHumanAuthoredUserTurn } from '@shared/utils/userTurnProvenance';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as readline from 'readline';
|
||||
|
||||
|
|
@ -283,6 +284,10 @@ export async function extractFirstUserMessagePreview(
|
|||
}
|
||||
|
||||
function extractPreviewFromUserEntry(entry: UserEntry): MessagePreview | null {
|
||||
if (!isHumanAuthoredUserTurn(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = entry.timestamp ?? new Date().toISOString();
|
||||
const message = entry.message;
|
||||
if (!message) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
getProviderDisconnectAction,
|
||||
isConnectionManagedRuntimeProvider,
|
||||
isOpenCodeCatalogHydrating,
|
||||
isProviderInventoryOnlyFallback,
|
||||
shouldShowProviderConnectAction,
|
||||
shouldShowProviderStatusSkeleton,
|
||||
} from '@renderer/components/runtime/providerConnectionUi';
|
||||
|
|
@ -527,6 +528,10 @@ function isPendingMultimodelProviderStatus(provider: CliProviderStatus): boolean
|
|||
);
|
||||
}
|
||||
|
||||
function isProviderCountedAsConnected(provider: CliProviderStatus): boolean {
|
||||
return provider.authenticated || isProviderInventoryOnlyFallback(provider);
|
||||
}
|
||||
|
||||
function formatRuntimeAuthSummary(
|
||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
|
||||
visibleProviders: readonly CliProviderStatus[],
|
||||
|
|
@ -541,7 +546,7 @@ function formatRuntimeAuthSummary(
|
|||
return t('cliStatus.provider.checkingProviders');
|
||||
}
|
||||
const denominator = visibleProviders.length;
|
||||
const connected = visibleProviders.filter((provider) => provider.authenticated).length;
|
||||
const connected = visibleProviders.filter(isProviderCountedAsConnected).length;
|
||||
|
||||
return t('cliStatus.provider.connectedCount', { connected, denominator });
|
||||
}
|
||||
|
|
@ -571,7 +576,7 @@ function isCheckingMultimodelStatus(
|
|||
function hasVisibleAuthenticatedMultimodelProvider(
|
||||
visibleProviders: readonly CliProviderStatus[]
|
||||
): boolean {
|
||||
return visibleProviders.some((provider) => provider.authenticated);
|
||||
return visibleProviders.some(isProviderCountedAsConnected);
|
||||
}
|
||||
|
||||
function isOpenCodeProviderEffectivelyReady(provider: CliProviderStatus): boolean {
|
||||
|
|
@ -1759,9 +1764,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
setProviderTerminal(null);
|
||||
recheckAuthState();
|
||||
}}
|
||||
onExit={() => {
|
||||
recheckAuthState();
|
||||
}}
|
||||
autoCloseOnSuccessMs={3000}
|
||||
successMessage={
|
||||
providerTerminal.action === 'login'
|
||||
|
|
@ -2367,21 +2369,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
}
|
||||
})();
|
||||
}}
|
||||
onExit={() => {
|
||||
setIsVerifyingAuth(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await invalidateCliStatus();
|
||||
if (multimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingAuth(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
autoCloseOnSuccessMs={4000}
|
||||
successMessage={t('cliStatus.labels.loginComplete')}
|
||||
failureMessage={t('cliStatus.labels.loginFailed')}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue