merge: dev into main

# Conflicts:
#	.github/workflows/reviewrouter-codex.yml
#	.github/workflows/reviewrouter-interaction.yml
This commit is contained in:
777genius 2026-05-28 02:07:36 +03:00
commit c49d6c373e
198 changed files with 15694 additions and 1720 deletions

52
.dockerignore Normal file
View 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

View file

@ -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

View file

@ -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
View 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.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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."

View file

@ -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
View file

@ -0,0 +1 @@
24.16.0

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
24.16.0

View file

@ -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>&nbsp;
<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>&nbsp;
<a href="https://github.com/777genius/agent-teams-ai/actions/workflows/ci.yml"><img src="https://github.com/777genius/agent-teams-ai/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp;
<a href="https://discord.gg/qtqSZSyuEc"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
@ -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)

View file

@ -14,6 +14,6 @@
"test:watch": "vitest --config vitest.config.js"
},
"engines": {
"node": ">=20"
"node": ">=24.16.0 <25"
}
}

View file

@ -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

View file

@ -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
}
})

View file

@ -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.

View file

@ -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
View file

@ -0,0 +1 @@
engine-strict=true

View file

@ -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",

View file

@ -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);

View file

@ -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",

View file

@ -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": {

View file

@ -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",

View file

@ -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" },

View file

@ -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" />

View file

@ -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

View file

@ -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="Скопировано" />

View file

@ -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).

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

View file

@ -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");

View file

@ -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"
}
}

View file

@ -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,

View file

@ -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",

View file

@ -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": {

File diff suppressed because it is too large Load diff

View file

@ -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"
}

View file

@ -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`
);
}

View file

@ -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),
});
}

View file

@ -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;

View 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}`);
}

View 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,
});
}

View file

@ -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',
}
);

View file

@ -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',
}
);

View file

@ -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',
}
);

View file

@ -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',
}
);

View file

@ -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',
}
);

View file

@ -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',
}
);

View file

@ -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',
}
);

View file

@ -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':

View file

@ -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,

View file

@ -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);

View file

@ -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);

View file

@ -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'

View file

@ -884,6 +884,7 @@
},
"status": {
"checking": "Checking...",
"modelsAvailable": "Models available",
"checked": "Checked",
"providerActivity": "Provider Activity",
"notConnected": "Not connected",

View file

@ -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": "Не подключено",

View file

@ -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';

View file

@ -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({

View file

@ -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(

View file

@ -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>
);
};

View file

@ -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 ? (

View file

@ -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 };

View file

@ -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 };

View file

@ -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,
};
}

View file

@ -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 (

View file

@ -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 };
},
};

View file

@ -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)),

View file

@ -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;
}

View file

@ -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 (

View file

@ -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',

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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) {

View file

@ -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();
}
/**

View file

@ -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,

View file

@ -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') {

View file

@ -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);
}
}

View file

@ -48,6 +48,7 @@ export function splitSettingsJsonArgs(args: string[]): SplitSettingsJsonArgsResu
const parsed = parseJsonSettingsObject(value);
if (parsed) {
settingsFragments.push(parsed);
index += 1;
continue;
}
}

View file

@ -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);
}

View file

@ -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 };

View file

@ -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

View file

@ -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;
}

View file

@ -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 {

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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) ||

View file

@ -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`

View file

@ -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);

View file

@ -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 {

View file

@ -14,6 +14,8 @@ export type {
TeamRuntimeMemberStopEvidence,
TeamRuntimePendingApproval,
TeamRuntimePendingPermission,
TeamRuntimePermissionListInput,
TeamRuntimePermissionListResult,
TeamRuntimePrepareFailure,
TeamRuntimePrepareResult,
TeamRuntimePrepareSuccess,

View file

@ -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;

View file

@ -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);
}

View file

@ -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

View file

@ -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) {

View file

@ -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