diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c2d844c2 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ce84b163..d6e5dc07 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 5d3171a1..b0cb7b8b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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//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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..2154748f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index a2956b5d..4f22c369 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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. diff --git a/.github/badges/version.svg b/.github/badges/version.svg deleted file mode 100644 index 274b436e..00000000 --- a/.github/badges/version.svg +++ /dev/null @@ -1 +0,0 @@ -version: v2.1.2versionv2.1.2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6856f31..4295bf21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/codex-runtime-smoke.yml b/.github/workflows/codex-runtime-smoke.yml index c162a884..97143b73 100644 --- a/.github/workflows/codex-runtime-smoke.yml +++ b/.github/workflows/codex-runtime-smoke.yml @@ -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 diff --git a/.github/workflows/landing.yml b/.github/workflows/landing.yml index 62325b21..f61bf286 100644 --- a/.github/workflows/landing.yml +++ b/.github/workflows/landing.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d173a17d..8aed232f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" <version: ${BADGE_VALUE}version${BADGE_VALUE} - 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." diff --git a/.github/workflows/reviewrouter-interaction.yml b/.github/workflows/reviewrouter-interaction.yml index 259cb066..773b68d5 100644 --- a/.github/workflows/reviewrouter-interaction.yml +++ b/.github/workflows/reviewrouter-interaction.yml @@ -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 diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..b832e400 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.16.0 diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..b6f27f13 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..b832e400 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.16.0 diff --git a/README.md b/README.md index 5066777e..85a8d719 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- Latest Release  + Latest Release  CI Status  Discord

@@ -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
-**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) diff --git a/agent-teams-controller/package.json b/agent-teams-controller/package.json index ce27fce1..902e6d05 100644 --- a/agent-teams-controller/package.json +++ b/agent-teams-controller/package.json @@ -14,6 +14,6 @@ "test:watch": "vitest --config vitest.config.js" }, "engines": { - "node": ">=20" + "node": ">=24.16.0 <25" } } diff --git a/docker/Dockerfile b/docker/Dockerfile index 51fe7153..2959e570 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/vite.standalone.config.ts b/docker/vite.standalone.config.ts index 8179e125..703cd07f 100644 --- a/docker/vite.standalone.config.ts +++ b/docker/vite.standalone.config.ts @@ -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 } }) diff --git a/docs/iterations/edit-project/plan-architecture.md b/docs/iterations/edit-project/plan-architecture.md index 33f8f4eb..c32bb0e6 100644 --- a/docs/iterations/edit-project/plan-architecture.md +++ b/docs/iterations/edit-project/plan-architecture.md @@ -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. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 1c6486c5..e157ceea 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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') diff --git a/landing/.npmrc b/landing/.npmrc new file mode 100644 index 00000000..b6f27f13 --- /dev/null +++ b/landing/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/landing/composables/usePageSeo.ts b/landing/composables/usePageSeo.ts index 61b0c852..e8ee947c 100644 --- a/landing/composables/usePageSeo.ts +++ b/landing/composables/usePageSeo.ts @@ -39,7 +39,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa const resolvedImage = computed(() => { 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", diff --git a/landing/error.vue b/landing/error.vue index 0afcf215..8fbb7c3c 100644 --- a/landing/error.vue +++ b/landing/error.vue @@ -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); diff --git a/landing/nuxt.config.ts b/landing/nuxt.config.ts index a8bdd199..d6219bed 100644 --- a/landing/nuxt.config.ts +++ b/landing/nuxt.config.ts @@ -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", diff --git a/landing/package-lock.json b/landing/package-lock.json index 8a4d6beb..5841cc0b 100644 --- a/landing/package-lock.json +++ b/landing/package-lock.json @@ -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": { diff --git a/landing/package.json b/landing/package.json index 9d0ec642..fda278ca 100644 --- a/landing/package.json +++ b/landing/package.json @@ -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", diff --git a/landing/product-docs/.vitepress/config.ts b/landing/product-docs/.vitepress/config.ts index 47b17ff2..18b48fa5 100644 --- a/landing/product-docs/.vitepress/config.ts +++ b/landing/product-docs/.vitepress/config.ts @@ -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" }, diff --git a/landing/product-docs/guide/installation.md b/landing/product-docs/guide/installation.md index 80b7c747..44230025 100644 --- a/landing/product-docs/guide/installation.md +++ b/landing/product-docs/guide/installation.md @@ -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 diff --git a/landing/product-docs/guide/quickstart.md b/landing/product-docs/guide/quickstart.md index 90d09ba8..744f70bd 100644 --- a/landing/product-docs/guide/quickstart.md +++ b/landing/product-docs/guide/quickstart.md @@ -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 diff --git a/landing/product-docs/ru/guide/installation.md b/landing/product-docs/ru/guide/installation.md index 60eec9b5..a36bdb70 100644 --- a/landing/product-docs/ru/guide/installation.md +++ b/landing/product-docs/ru/guide/installation.md @@ -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+. + ## Запуск из исходников diff --git a/landing/product-docs/ru/guide/quickstart.md b/landing/product-docs/ru/guide/quickstart.md index 60d5f9ea..b7cffe38 100644 --- a/landing/product-docs/ru/guide/quickstart.md +++ b/landing/product-docs/ru/guide/quickstart.md @@ -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). diff --git a/landing/public/og-image-agent-teams-v6.png b/landing/public/og-image-agent-teams-v6.png new file mode 100644 index 00000000..52e586fc Binary files /dev/null and b/landing/public/og-image-agent-teams-v6.png differ diff --git a/landing/server/routes/sitemap.xml.ts b/landing/server/routes/sitemap.xml.ts index fdfa4aea..b8fd3e3c 100644 --- a/landing/server/routes/sitemap.xml.ts +++ b/landing/server/routes/sitemap.xml.ts @@ -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"); diff --git a/mcp-server/package.json b/mcp-server/package.json index 35ccc15e..71e2a285 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -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" } } diff --git a/mcp-server/tsup.config.ts b/mcp-server/tsup.config.ts index 86520de5..01d2b622 100644 --- a/mcp-server/tsup.config.ts +++ b/mcp-server/tsup.config.ts @@ -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, diff --git a/package.json b/package.json index 279441a7..ebbda85d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "bugs": { "url": "https://github.com/777genius/agent-teams-ai/issues" }, + "engines": { + "node": ">=24.16.0 <25" + }, "main": "dist-electron/main/index.cjs", "scripts": { "dev": "node ./scripts/dev-with-runtime.mjs", @@ -83,10 +86,10 @@ "test:coverage": "vitest run --coverage", "test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts", "standalone": "tsx src/main/standalone.ts", - "standalone:build": "electron-vite build && vite build --config docker/vite.standalone.config.ts", + "standalone:build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build && node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --config docker/vite.standalone.config.ts", "standalone:start": "node dist-standalone/index.cjs", "prepare": "husky", - "postinstall": "electron-rebuild -f -o node-pty,ssh2,cpu-features || echo 'native Electron rebuild failed (terminal/ssh features may be degraded)'" + "postinstall": "electron-rebuild -f -o node-pty,ssh2,cpu-features || echo 'native Electron rebuild failed (terminal/ssh features may be degraded)'; node ./scripts/ensure-electron-install.cjs" }, "lint-staged": { "src/**/*.{ts,tsx,js,jsx}": [ @@ -212,7 +215,7 @@ "@tailwindcss/typography": "^0.5.19", "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", - "@types/node": "^25.0.7", + "@types/node": "^24.12.4", "@types/pidusage": "2.0.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -388,7 +391,7 @@ } ] }, - "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", + "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800", "pnpm": { "overrides": { "@hono/node-server@1": "1.19.13", diff --git a/packages/agent-graph/package.json b/packages/agent-graph/package.json index fef2c312..8eca5d33 100644 --- a/packages/agent-graph/package.json +++ b/packages/agent-graph/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b866d2f..a635693b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -402,7 +402,7 @@ importers: version: 4.0.4 '@eslint-community/eslint-plugin-eslint-comments': specifier: ^4.6.0 - version: 4.6.0(eslint@9.39.4(jiti@1.21.7)) + version: 4.6.0(eslint@9.39.4(jiti@2.7.0)) '@eslint/js': specifier: ^9.39.2 version: 9.39.2 @@ -419,8 +419,8 @@ importers: specifier: ^4.0.4 version: 4.0.4 '@types/node': - specifier: ^25.0.7 - version: 25.0.7 + specifier: ^24.12.4 + version: 24.12.4 '@types/pidusage': specifier: 2.0.5 version: 2.0.5 @@ -435,10 +435,10 @@ importers: version: 1.15.5 '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.7.0(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.7.0(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^3.1.4 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) autoprefixer: specifier: ^10.4.17 version: 10.4.23(postcss@8.5.10) @@ -450,43 +450,43 @@ importers: version: 26.8.1(electron-builder-squirrel-windows@26.8.1) electron-vite: specifier: ^5.0.0 - version: 5.0.0(@swc/core@1.15.33)(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 5.0.0(@swc/core@1.15.33)(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) eslint: specifier: ^9.39.4 - version: 9.39.4(jiti@1.21.7) + version: 9.39.4(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.4(jiti@1.21.7)) + version: 10.1.8(eslint@9.39.4(jiti@2.7.0)) eslint-import-resolver-typescript: specifier: ^4.4.4 - version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7)) + version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-boundaries: specifier: ^5.3.1 - version: 5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)) + version: 5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)) + version: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: specifier: ^6.10.2 - version: 6.10.2(eslint@9.39.4(jiti@1.21.7)) + version: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: specifier: ^7.37.5 - version: 7.37.5(eslint@9.39.4(jiti@1.21.7)) + version: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.4(jiti@1.21.7)) + version: 7.0.1(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-refresh: specifier: ^0.4.26 - version: 0.4.26(eslint@9.39.4(jiti@1.21.7)) + version: 0.4.26(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-security: specifier: ^3.0.1 version: 3.0.1 eslint-plugin-simple-import-sort: specifier: ^12.1.1 - version: 12.1.1(eslint@9.39.4(jiti@1.21.7)) + version: 12.1.1(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-sonarjs: specifier: ^3.0.6 - version: 3.0.6(eslint@9.39.4(jiti@1.21.7)) + version: 3.0.6(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-tailwindcss: specifier: ^3.18.2 version: 3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0)) @@ -501,10 +501,10 @@ importers: version: 9.1.7 i18next-cli: specifier: 1.58.0 - version: 1.58.0(@types/node@25.0.7)(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(typescript@5.9.3) + version: 1.58.0(@types/node@24.12.4)(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(typescript@5.9.3) knip: specifier: ^5.82.1 - version: 5.82.1(@types/node@25.0.7)(typescript@5.9.3) + version: 5.82.1(@types/node@24.12.4)(typescript@5.9.3) lint-staged: specifier: ^16.2.7 version: 16.2.7 @@ -528,13 +528,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.54.0 - version: 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + version: 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) vite: specifier: ^6.4.2 - version: 6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) + version: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: ^3.1.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) agent-teams-controller: {} @@ -636,8 +636,8 @@ importers: version: 4.3.6 devDependencies: '@types/node': - specifier: ^22.15.18 - version: 22.19.15 + specifier: ^24.12.4 + version: 24.12.4 tsup: specifier: ^8.5.1 version: 8.5.1(@swc/core@1.15.33)(jiti@2.7.0)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) @@ -649,7 +649,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.1.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) packages/agent-graph: dependencies: @@ -4723,11 +4723,8 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@22.19.15': - resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} - - '@types/node@24.10.12': - resolution: {integrity: sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==} + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} '@types/node@25.0.7': resolution: {integrity: sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==} @@ -10727,9 +10724,6 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -11859,10 +11853,10 @@ snapshots: '@borewit/text-codec@0.2.1': {} - '@boundaries/elements@1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))': + '@boundaries/elements@1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))': dependencies: eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)) handlebars: 4.7.9 is-core-module: 2.16.1 micromatch: 4.0.8 @@ -12668,17 +12662,12 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true - '@eslint-community/eslint-plugin-eslint-comments@4.6.0(eslint@9.39.4(jiti@1.21.7))': + '@eslint-community/eslint-plugin-eslint-comments@4.6.0(eslint@9.39.4(jiti@2.7.0))': dependencies: escape-string-regexp: 4.0.0 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) ignore: 7.0.5 - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': - dependencies: - eslint: 9.39.4(jiti@1.21.7) - eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': dependencies: eslint: 9.39.4(jiti@2.7.0) @@ -12870,122 +12859,122 @@ snapshots: '@inquirer/ansi@2.0.5': {} - '@inquirer/checkbox@5.1.5(@types/node@25.0.7)': + '@inquirer/checkbox@5.1.5(@types/node@24.12.4)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.10(@types/node@25.0.7) + '@inquirer/core': 11.1.10(@types/node@24.12.4) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/type': 4.0.5(@types/node@24.12.4) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/confirm@6.0.13(@types/node@25.0.7)': + '@inquirer/confirm@6.0.13(@types/node@24.12.4)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.0.7) - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/core': 11.1.10(@types/node@24.12.4) + '@inquirer/type': 4.0.5(@types/node@24.12.4) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/core@11.1.10(@types/node@25.0.7)': + '@inquirer/core@11.1.10(@types/node@24.12.4)': dependencies: '@inquirer/ansi': 2.0.5 '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/type': 4.0.5(@types/node@24.12.4) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/editor@5.1.2(@types/node@25.0.7)': + '@inquirer/editor@5.1.2(@types/node@24.12.4)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.0.7) - '@inquirer/external-editor': 3.0.0(@types/node@25.0.7) - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/core': 11.1.10(@types/node@24.12.4) + '@inquirer/external-editor': 3.0.0(@types/node@24.12.4) + '@inquirer/type': 4.0.5(@types/node@24.12.4) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/expand@5.0.14(@types/node@25.0.7)': + '@inquirer/expand@5.0.14(@types/node@24.12.4)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.0.7) - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/core': 11.1.10(@types/node@24.12.4) + '@inquirer/type': 4.0.5(@types/node@24.12.4) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/external-editor@3.0.0(@types/node@25.0.7)': + '@inquirer/external-editor@3.0.0(@types/node@24.12.4)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 '@inquirer/figures@2.0.5': {} - '@inquirer/input@5.0.13(@types/node@25.0.7)': + '@inquirer/input@5.0.13(@types/node@24.12.4)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.0.7) - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/core': 11.1.10(@types/node@24.12.4) + '@inquirer/type': 4.0.5(@types/node@24.12.4) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/number@4.0.13(@types/node@25.0.7)': + '@inquirer/number@4.0.13(@types/node@24.12.4)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.0.7) - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/core': 11.1.10(@types/node@24.12.4) + '@inquirer/type': 4.0.5(@types/node@24.12.4) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/password@5.0.13(@types/node@25.0.7)': + '@inquirer/password@5.0.13(@types/node@24.12.4)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.10(@types/node@25.0.7) - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/core': 11.1.10(@types/node@24.12.4) + '@inquirer/type': 4.0.5(@types/node@24.12.4) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/prompts@8.4.3(@types/node@25.0.7)': + '@inquirer/prompts@8.4.3(@types/node@24.12.4)': dependencies: - '@inquirer/checkbox': 5.1.5(@types/node@25.0.7) - '@inquirer/confirm': 6.0.13(@types/node@25.0.7) - '@inquirer/editor': 5.1.2(@types/node@25.0.7) - '@inquirer/expand': 5.0.14(@types/node@25.0.7) - '@inquirer/input': 5.0.13(@types/node@25.0.7) - '@inquirer/number': 4.0.13(@types/node@25.0.7) - '@inquirer/password': 5.0.13(@types/node@25.0.7) - '@inquirer/rawlist': 5.2.9(@types/node@25.0.7) - '@inquirer/search': 4.1.9(@types/node@25.0.7) - '@inquirer/select': 5.1.5(@types/node@25.0.7) + '@inquirer/checkbox': 5.1.5(@types/node@24.12.4) + '@inquirer/confirm': 6.0.13(@types/node@24.12.4) + '@inquirer/editor': 5.1.2(@types/node@24.12.4) + '@inquirer/expand': 5.0.14(@types/node@24.12.4) + '@inquirer/input': 5.0.13(@types/node@24.12.4) + '@inquirer/number': 4.0.13(@types/node@24.12.4) + '@inquirer/password': 5.0.13(@types/node@24.12.4) + '@inquirer/rawlist': 5.2.9(@types/node@24.12.4) + '@inquirer/search': 4.1.9(@types/node@24.12.4) + '@inquirer/select': 5.1.5(@types/node@24.12.4) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/rawlist@5.2.9(@types/node@25.0.7)': + '@inquirer/rawlist@5.2.9(@types/node@24.12.4)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.0.7) - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/core': 11.1.10(@types/node@24.12.4) + '@inquirer/type': 4.0.5(@types/node@24.12.4) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/search@4.1.9(@types/node@25.0.7)': + '@inquirer/search@4.1.9(@types/node@24.12.4)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.0.7) + '@inquirer/core': 11.1.10(@types/node@24.12.4) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/type': 4.0.5(@types/node@24.12.4) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/select@5.1.5(@types/node@25.0.7)': + '@inquirer/select@5.1.5(@types/node@24.12.4)': dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.10(@types/node@25.0.7) + '@inquirer/core': 11.1.10(@types/node@24.12.4) '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/type': 4.0.5(@types/node@24.12.4) optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 - '@inquirer/type@4.0.5(@types/node@25.0.7)': + '@inquirer/type@4.0.5(@types/node@24.12.4)': optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 '@intlify/bundle-utils@10.0.1(vue-i18n@10.0.8(vue@3.5.30(typescript@5.9.3)))': dependencies: @@ -15677,7 +15666,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.0.4 '@types/keyv': 3.1.4 - '@types/node': 25.0.7 + '@types/node': 24.12.4 '@types/responselike': 1.0.3 '@types/chai@5.2.3': @@ -15687,7 +15676,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 '@types/d3-array@3.2.2': {} @@ -15820,7 +15809,7 @@ snapshots: '@types/fs-extra@9.0.13': dependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 '@types/geojson@7946.0.16': {} @@ -15836,7 +15825,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 '@types/linkify-it@5.0.0': {} @@ -15855,23 +15844,20 @@ snapshots: '@types/mysql@2.15.27': dependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 '@types/node@18.19.130': dependencies: undici-types: 5.26.5 - '@types/node@22.19.15': - dependencies: - undici-types: 6.21.0 - - '@types/node@24.10.12': + '@types/node@24.12.4': dependencies: undici-types: 7.16.0 '@types/node@25.0.7': dependencies: undici-types: 7.16.0 + optional: true '@types/pg-pool@2.0.7': dependencies: @@ -15879,7 +15865,7 @@ snapshots: '@types/pg@8.15.6': dependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 pg-protocol: 1.13.0 pg-types: 2.2.0 @@ -15887,7 +15873,7 @@ snapshots: '@types/plist@3.0.5': dependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 xmlbuilder: 15.1.1 optional: true @@ -15903,7 +15889,7 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 '@types/ssh2@1.15.5': dependencies: @@ -15911,7 +15897,7 @@ snapshots: '@types/tedious@4.0.14': dependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 '@types/trusted-types@2.0.7': optional: true @@ -15933,22 +15919,22 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 optional: true - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.54.0 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -15972,14 +15958,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.54.0 debug: 4.4.3 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -16032,13 +16018,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -16090,29 +16076,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/types': 8.54.0 '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) - eslint: 9.39.4(jiti@1.21.7) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - optional: true - '@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) @@ -16225,7 +16199,7 @@ snapshots: - rollup - supports-color - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))': + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -16233,7 +16207,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -16261,7 +16235,7 @@ snapshots: vite: 7.3.3(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) vue: 3.5.34(typescript@5.9.3) - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -16276,7 +16250,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -16288,21 +16262,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@22.19.15)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@22.19.15)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) - - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -17997,7 +17963,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(@swc/core@1.15.33)(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)): + electron-vite@5.0.0(@swc/core@1.15.33)(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) @@ -18005,7 +17971,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) optionalDependencies: '@swc/core': 1.15.33 transitivePeerDependencies: @@ -18026,7 +17992,7 @@ snapshots: electron@40.10.0: dependencies: '@electron/get': 2.0.3 - '@types/node': 24.10.12 + '@types/node': 24.12.4 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -18318,9 +18284,9 @@ snapshots: '@eslint/compat': 2.0.3(eslint@9.39.4(jiti@2.7.0)) eslint: 9.39.4(jiti@2.7.0) - eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@1.21.7)): + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)): dependencies: - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) eslint-flat-config-utils@3.0.2: dependencies: @@ -18342,10 +18308,10 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7)): + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 4.4.3 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) get-tsconfig: 4.13.0 is-bun-module: 2.0.0 @@ -18353,8 +18319,8 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)) - eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -18362,24 +18328,24 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.7.0) - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.4(jiti@1.21.7) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-boundaries@5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-boundaries@5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)): dependencies: - '@boundaries/elements': 1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)) + '@boundaries/elements': 1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)) chalk: 4.1.2 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)) micromatch: 4.0.8 transitivePeerDependencies: - '@typescript-eslint/parser' @@ -18391,26 +18357,6 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.7.0) - eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)): - dependencies: - '@package-json/types': 0.0.12 - '@typescript-eslint/types': 8.57.1 - comment-parser: 1.4.5 - debug: 4.4.3 - eslint: 9.39.4(jiti@1.21.7) - eslint-import-context: 0.1.9(unrs-resolver@1.11.1) - is-glob: 4.0.3 - minimatch: 9.0.7 - semver: 7.7.4 - stable-hash-x: 0.2.0 - unrs-resolver: 1.11.1 - optionalDependencies: - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - eslint-import-resolver-node: 0.3.9 - transitivePeerDependencies: - - supports-color - optional: true - eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@package-json/types': 0.0.12 @@ -18430,7 +18376,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -18439,9 +18385,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -18453,7 +18399,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -18479,7 +18425,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.7.0)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -18489,7 +18435,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -18498,22 +18444,22 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.7.0)): dependencies: '@babel/core': 7.28.6 '@babel/parser': 7.28.6 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.7.0)): dependencies: - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) - eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.7.0)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -18521,7 +18467,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.2 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -18550,16 +18496,16 @@ snapshots: dependencies: safe-regex: 2.1.1 - eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.4(jiti@2.7.0)): dependencies: - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) - eslint-plugin-sonarjs@3.0.6(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-sonarjs@3.0.6(eslint@9.39.4(jiti@2.7.0)): dependencies: '@eslint-community/regexpp': 4.12.2 builtin-modules: 3.3.0 bytes: 3.1.2 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.4(jiti@2.7.0) functional-red-black-tree: 1.0.1 jsx-ast-utils-x: 0.1.0 lodash.merge: 4.6.2 @@ -18630,47 +18576,6 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@1.21.7): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.4 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 1.21.7 - transitivePeerDependencies: - - supports-color - eslint@9.39.4(jiti@2.7.0): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) @@ -19368,7 +19273,7 @@ snapshots: happy-dom@20.9.0: dependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -19601,7 +19506,7 @@ snapshots: husky@9.1.7: {} - i18next-cli@1.58.0(@types/node@25.0.7)(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(typescript@5.9.3): + i18next-cli@1.58.0(@types/node@24.12.4)(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(typescript@5.9.3): dependencies: '@croct/json5-parser': 0.2.2 '@swc/core': 1.15.33 @@ -19610,7 +19515,7 @@ snapshots: execa: 9.6.1 glob: 13.0.6 i18next-resources-for-ts: 2.1.0 - inquirer: 13.4.3(@types/node@25.0.7) + inquirer: 13.4.3(@types/node@24.12.4) jiti: 2.7.0 jsonc-parser: 3.3.1 magic-string: 0.30.21 @@ -19701,17 +19606,17 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@13.4.3(@types/node@25.0.7): + inquirer@13.4.3(@types/node@24.12.4): dependencies: '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.10(@types/node@25.0.7) - '@inquirer/prompts': 8.4.3(@types/node@25.0.7) - '@inquirer/type': 4.0.5(@types/node@25.0.7) + '@inquirer/core': 11.1.10(@types/node@24.12.4) + '@inquirer/prompts': 8.4.3(@types/node@24.12.4) + '@inquirer/type': 4.0.5(@types/node@24.12.4) mute-stream: 3.0.0 run-async: 4.0.6 rxjs: 7.8.2 optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 internal-slot@1.1.0: dependencies: @@ -20086,10 +19991,10 @@ snapshots: klona@2.0.6: {} - knip@5.82.1(@types/node@25.0.7)(typescript@5.9.3): + knip@5.82.1(@types/node@24.12.4)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 - '@types/node': 25.0.7 + '@types/node': 24.12.4 fast-glob: 3.3.3 formatly: 0.3.0 jiti: 2.6.1 @@ -23362,13 +23267,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): + typescript-eslint@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.4(jiti@1.21.7) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -23406,8 +23311,6 @@ snapshots: undici-types@5.26.5: {} - undici-types@6.21.0: {} - undici-types@7.16.0: {} undici@6.25.0: {} @@ -23740,34 +23643,13 @@ snapshots: dependencies: vite: 7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) - vite-node@3.2.4(@types/node@22.19.15)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): + vite-node@3.2.4(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@22.19.15)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - vite-node@3.2.4(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -23857,7 +23739,7 @@ snapshots: transitivePeerDependencies: - supports-color - vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): + vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -23866,24 +23748,7 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.0.7 - fsevents: 2.3.3 - jiti: 1.21.7 - sass: 1.98.0 - terser: 5.46.0 - tsx: 4.21.0 - yaml: 2.9.0 - - vite@7.3.2(@types/node@22.19.15)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): - dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.10 - rollup: 4.59.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.19.15 + '@types/node': 24.12.4 fsevents: 2.3.3 jiti: 2.7.0 sass: 1.98.0 @@ -23891,7 +23756,7 @@ snapshots: tsx: 4.21.0 yaml: 2.9.0 - vite@7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): + vite@7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -23900,9 +23765,9 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.0.7 + '@types/node': 24.12.4 fsevents: 2.3.3 - jiti: 1.21.7 + jiti: 2.7.0 sass: 1.98.0 terser: 5.46.0 tsx: 4.21.0 @@ -24014,11 +23879,11 @@ snapshots: - universal-cookie - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@22.19.15)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -24036,55 +23901,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@22.19.15)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@22.19.15)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 22.19.15 - happy-dom: 20.9.0 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): - dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.3.0 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/debug': 4.1.12 - '@types/node': 25.0.7 + '@types/node': 24.12.4 happy-dom: 20.9.0 transitivePeerDependencies: - jiti diff --git a/runtime.lock.json b/runtime.lock.json index 043037c6..ad88da32 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -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" } diff --git a/scripts/ci/verify-sentry-release.cjs b/scripts/ci/verify-sentry-release.cjs index 4722b4f1..98952376 100644 --- a/scripts/ci/verify-sentry-release.cjs +++ b/scripts/ci/verify-sentry-release.cjs @@ -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` ); } diff --git a/scripts/dev-web.mjs b/scripts/dev-web.mjs index 159415ef..f3f56971 100644 --- a/scripts/dev-web.mjs +++ b/scripts/dev-web.mjs @@ -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), }); } diff --git a/scripts/dev-with-runtime.mjs b/scripts/dev-with-runtime.mjs index 1ac19992..28f14d67 100644 --- a/scripts/dev-with-runtime.mjs +++ b/scripts/dev-with-runtime.mjs @@ -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; diff --git a/scripts/ensure-electron-install.cjs b/scripts/ensure-electron-install.cjs new file mode 100644 index 00000000..af85c37a --- /dev/null +++ b/scripts/ensure-electron-install.cjs @@ -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}`); +} diff --git a/scripts/lib/windows-shell-spawn.mjs b/scripts/lib/windows-shell-spawn.mjs new file mode 100644 index 00000000..002e6fef --- /dev/null +++ b/scripts/lib/windows-shell-spawn.mjs @@ -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, + }); +} diff --git a/scripts/prove-agent-cli-launch.mjs b/scripts/prove-agent-cli-launch.mjs index eaf63af5..980f0169 100644 --- a/scripts/prove-agent-cli-launch.mjs +++ b/scripts/prove-agent-cli-launch.mjs @@ -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', } ); diff --git a/scripts/prove-opencode-mixed-recovery.mjs b/scripts/prove-opencode-mixed-recovery.mjs index 3a896a7f..59845aa2 100644 --- a/scripts/prove-opencode-mixed-recovery.mjs +++ b/scripts/prove-opencode-mixed-recovery.mjs @@ -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', } ); diff --git a/scripts/prove-opencode-semantic-gauntlet.mjs b/scripts/prove-opencode-semantic-gauntlet.mjs index 031b18a1..8366d98b 100644 --- a/scripts/prove-opencode-semantic-gauntlet.mjs +++ b/scripts/prove-opencode-semantic-gauntlet.mjs @@ -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', } ); diff --git a/scripts/prove-opencode-semantic-messaging.mjs b/scripts/prove-opencode-semantic-messaging.mjs index 1eee1cb0..a62877a8 100644 --- a/scripts/prove-opencode-semantic-messaging.mjs +++ b/scripts/prove-opencode-semantic-messaging.mjs @@ -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', } ); diff --git a/scripts/prove-opencode-semantic-model-matrix.mjs b/scripts/prove-opencode-semantic-model-matrix.mjs index 9f91862f..1b74e5c6 100644 --- a/scripts/prove-opencode-semantic-model-matrix.mjs +++ b/scripts/prove-opencode-semantic-model-matrix.mjs @@ -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', } ); diff --git a/scripts/prove-opencode-team-provisioning.mjs b/scripts/prove-opencode-team-provisioning.mjs index 12673fd3..5cca86a3 100644 --- a/scripts/prove-opencode-team-provisioning.mjs +++ b/scripts/prove-opencode-team-provisioning.mjs @@ -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', } ); diff --git a/scripts/prove-provider-launch-stress.mjs b/scripts/prove-provider-launch-stress.mjs index eaea932b..79665188 100644 --- a/scripts/prove-provider-launch-stress.mjs +++ b/scripts/prove-provider-launch-stress.mjs @@ -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', } ); diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 25d11a8b..ce88d46c 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -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 | 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': diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index 6ea2258d..3bdce9f2 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -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, diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts index 5a9f39ec..80fd3c5e 100644 --- a/src/features/codex-account/main/composition/createCodexAccountFeature.ts +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -263,7 +263,8 @@ async function resolveCodexBinaryForAccountSnapshot(): Promise { 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 | 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); diff --git a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts index 1269ffd6..368d590f 100644 --- a/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts +++ b/src/features/codex-account/renderer/hooks/useCodexAccountSnapshot.ts @@ -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(null); const [visible, setVisible] = useState(() => isDocumentVisible()); const lastUpdatedAtRef = useRef(null); + const snapshotUpdatedAtRef = useRef(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); diff --git a/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts b/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts index 8980cf69..620cd1f7 100644 --- a/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts +++ b/src/features/codex-account/renderer/mergeCodexProviderStatusWithSnapshot.ts @@ -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' diff --git a/src/features/localization/renderer/locales/en/settings.json b/src/features/localization/renderer/locales/en/settings.json index 2e8e2e23..d41f7f8c 100644 --- a/src/features/localization/renderer/locales/en/settings.json +++ b/src/features/localization/renderer/locales/en/settings.json @@ -884,6 +884,7 @@ }, "status": { "checking": "Checking...", + "modelsAvailable": "Models available", "checked": "Checked", "providerActivity": "Provider Activity", "notConnected": "Not connected", diff --git a/src/features/localization/renderer/locales/ru/settings.json b/src/features/localization/renderer/locales/ru/settings.json index 7412d935..f9a89a76 100644 --- a/src/features/localization/renderer/locales/ru/settings.json +++ b/src/features/localization/renderer/locales/ru/settings.json @@ -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": "Не подключено", diff --git a/src/features/localization/renderer/resources.d.ts b/src/features/localization/renderer/resources.d.ts index 203d93e9..f70bbd5e 100644 --- a/src/features/localization/renderer/resources.d.ts +++ b/src/features/localization/renderer/resources.d.ts @@ -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'; diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts index bbb52f55..4dcbaf4e 100644 --- a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -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: 'Looks good', + }), + 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({ diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts index 4c6121a4..ae0a772d 100644 --- a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -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( diff --git a/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx index c383ffe9..f44e5e11 100644 --- a/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx +++ b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx @@ -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): 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 (
-
- - -
- - {selectedLogView === 'execution' ? ( - - ) : ( - - )} +
); }; diff --git a/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx b/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx index 6e165141..b8fb2192 100644 --- a/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx +++ b/src/features/member-log-stream/renderer/ui/ExecutionLogStreamView.tsx @@ -26,7 +26,7 @@ interface ParticipantVisual { export interface ExecutionLogStreamViewProps { title: string; - description: string; + description?: string; stream: TStream | null; loading: boolean; error: string | null; @@ -312,7 +312,9 @@ export const ExecutionLogStreamView = ({

{title}

-

{description}

+ {description ? ( +

{description}

+ ) : null} ) : null} {boundedHistoryNote ? ( diff --git a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts index 104ccb1a..ec27002a 100644 --- a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts +++ b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts @@ -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 => { + 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 }; diff --git a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts index a18ea436..cdfb1af7 100644 --- a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts +++ b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts @@ -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 }; diff --git a/src/features/recent-projects/main/adapters/input/recentProjectsDiagnostics.ts b/src/features/recent-projects/main/adapters/input/recentProjectsDiagnostics.ts new file mode 100644 index 00000000..f527af36 --- /dev/null +++ b/src/features/recent-projects/main/adapters/input/recentProjectsDiagnostics.ts @@ -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, + }; +} diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts index 2258fc39..3e8e7c7d 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts @@ -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; +} + 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 { - async function walk(directory: string, depth: number): Promise { - let entries; - try { - entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' }); - } catch { - return []; - } - - const files = await Promise.all( - entries.map(async (entry): Promise => { - 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 { + 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 { + 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 { + 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 { + 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 => { + 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 { 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 { 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(); - const candidateFiles = files.slice(0, CODEX_SESSION_FILE_PARSE_LIMIT); + const candidateFiles = files; const cache = await this.#readCacheSafe(); const nextCacheEntries = new Map(); - 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 { 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; if ( diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts index c85173d0..d447e8d4 100644 --- a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -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 }; }, }; diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 27f7c5d9..6c56f77d 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -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( initialSnapshot?.payload.projects ?? [] @@ -105,6 +113,8 @@ export function useRecentProjectsSection( const recentProjectsRef = useRef( 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 => { - const hasVisibleProjects = - recentProjectsRef.current.length > 0 || getRecentProjectsClientSnapshot() != null; + const reload = useCallback( + async (options?: { force?: boolean }): Promise => { + 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)), diff --git a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts index e6c18df8..a01f40ae 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts @@ -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 | null = null; +let inFlightLoad: { key: string; promise: Promise } | 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, options?: { force?: boolean } ): Promise { 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; } diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index fddc58c8..6b098ed0 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -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 ( diff --git a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts index dc6fb0f7..d7cd1d51 100644 --- a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts +++ b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts @@ -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', diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index 5eae2e40..11568134 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -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[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; } diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 16d618f3..9775cc43 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -38,6 +38,7 @@ const logger = createLogger('IPC:cliInstaller'); let service: CliInstallerService; const statusInFlight = new Map>(); const providerStatusInFlight = new Map>(); +let providerRuntimeRequestTail: Promise = Promise.resolve(); const cachedStatus = new Map< CliInstallerProviderStatusMode, { value: CliInstallationStatus; at: number } @@ -110,11 +111,21 @@ function canUseStatusForCacheKey( ); } +function runProviderRuntimeRequest(operation: () => Promise): Promise { + 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> { try { const generation = statusCacheGeneration; - const status = await service.verifyProviderModels(providerId); + const status = await runProviderRuntimeRequest(() => service.verifyProviderModels(providerId)); if (generation === statusCacheGeneration) { patchCachedProviderStatus(status); } diff --git a/src/main/services/analysis/SubagentDetailBuilder.ts b/src/main/services/analysis/SubagentDetailBuilder.ts index c6fc54d9..d6bdd1c6 100644 --- a/src/main/services/analysis/SubagentDetailBuilder.ts +++ b/src/main/services/analysis/SubagentDetailBuilder.ts @@ -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; +} diff --git a/src/main/services/discovery/SessionContentFilter.ts b/src/main/services/discovery/SessionContentFilter.ts index 64181188..bc1e721a 100644 --- a/src/main/services/discovery/SessionContentFilter.ts +++ b/src/main/services/discovery/SessionContentFilter.ts @@ -21,9 +21,15 @@ * - synthetic assistant messages (model='') */ +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 = ['', '']; - /** * 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) { diff --git a/src/main/services/discovery/SubagentResolver.ts b/src/main/services/discovery/SubagentResolver.ts index 240e8e80..a244110d 100644 --- a/src/main/services/discovery/SubagentResolver.ts +++ b/src/main/services/discovery/SubagentResolver.ts @@ -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 = /]*?\bteammate_id="([^"]+)"/.exec(text); - return match?.[1]; + const text = this.extractUserText(message); + const normalized = this.stripTranscriptSpeakerPrefix(text); + const match = /]*?\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(); } /** diff --git a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts index fef3a0cb..811e4356 100644 --- a/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts +++ b/src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts @@ -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(); +const versionProbeInFlight = new Map>(); +const pathProbeCache = new Map(); +const pathProbeInFlight = new Map>(); +const runtimeBinaryResolveCache = new Map(); +const runtimeBinaryResolveInFlight = new Map>(); +let runtimeResolverCacheGeneration = 0; + async function probeOpenCodeBinaryVersion(binaryPath: string): Promise { 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 { + 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, @@ -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 { @@ -268,20 +346,93 @@ async function probeFirstWorkingPathOpenCodeBinary( ); } +async function probeFirstWorkingPathOpenCodeBinaryCached( + options: OpenCodeRuntimeBinaryResolveOptions = {} +): Promise { + 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 { - 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 { - 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 | null = null; private latestStatus: OpenCodeRuntimeStatus | null = null; + private latestStatusAt = 0; + private statusPromise: Promise | 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 { 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 { 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 { - 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, diff --git a/src/main/services/parsing/SessionParser.ts b/src/main/services/parsing/SessionParser.ts index 3219f4b6..a3844a7b 100644 --- a/src/main/services/parsing/SessionParser.ts +++ b/src/main/services/parsing/SessionParser.ts @@ -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') { diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 448519cc..2e4b3cca 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -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; } 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 { + 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( + 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(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 { + 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[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); } } diff --git a/src/main/services/runtime/teamRuntimeSettingsBundle.ts b/src/main/services/runtime/teamRuntimeSettingsBundle.ts index 8a01ba3c..4901f465 100644 --- a/src/main/services/runtime/teamRuntimeSettingsBundle.ts +++ b/src/main/services/runtime/teamRuntimeSettingsBundle.ts @@ -48,6 +48,7 @@ export function splitSettingsJsonArgs(args: string[]): SplitSettingsJsonArgsResu const parsed = parseJsonSettingsObject(value); if (parsed) { settingsFragments.push(parsed); + index += 1; continue; } } diff --git a/src/main/services/team/TeamLaunchSummaryProjection.ts b/src/main/services/team/TeamLaunchSummaryProjection.ts index ac29deef..3dd6befb 100644 --- a/src/main/services/team/TeamLaunchSummaryProjection.ts +++ b/src/main/services/team/TeamLaunchSummaryProjection.ts @@ -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 | null { + let changed = false; + const projectedMembers: Record = {}; + 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); } diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index bb134149..2f18d73c 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -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 }; diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index a8b6f49b..9c99cd54 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -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; - 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(' 0 && @@ -2052,7 +2059,7 @@ export class TeamMemberLogsFinder { msg: Record, knownMembers: Set ): { 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): boolean { + if (this.extractRole(msg) !== 'user') return false; + return isHumanAuthoredUserTurn(msg) || isDisplayableTeammateProtocol(msg); + } + private extractTextContent(msg: Record): string | null { if (typeof msg.content === 'string') { return msg.content; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 86390c9b..045d3392 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -122,6 +122,7 @@ import { isTeamInternalControlMessageText, stripExactInternalControlEchoPrefix, } from '@shared/utils/teamInternalControlMessages'; +import { hasUnsafeProvisionedButNotAliveRuntimeEvidence } from '@shared/utils/teamLaunchFailureReason'; import { parseAllTeammateMessages, type ParsedTeammateContent, @@ -229,7 +230,9 @@ import { import { deriveMemberLaunchState, isAutoClearableLaunchFailureReason, + isCliProvisionedButNotAliveFailureReason, isNeverSpawnedDuringLaunchReason, + isProvisionedButNotAliveFailureReason, } from './provisioning/TeamProvisioningLaunchFailurePolicy'; import { isOpenCodeOverlayMemberRemoved, @@ -523,6 +526,8 @@ import type { TeamRuntimeLaunchResult, TeamRuntimeMemberLaunchEvidence, TeamRuntimeMemberSpec, + TeamRuntimePendingPermission, + TeamRuntimePermissionListResult, TeamRuntimePrepareResult, TeamRuntimeStopInput, } from './runtime'; @@ -540,6 +545,16 @@ type OpenCodeRuntimeMessageAdapter = TeamLaunchRuntimeAdapter & { ): Promise; }; +type OpenCodeRuntimePermissionListingAdapter = TeamLaunchRuntimeAdapter & { + listRuntimePermissions(input: { + teamName: string; + laneId: string; + cwd: string; + memberName?: string; + sessionId?: string | null; + }): Promise; +}; + /** * Kill a team CLI process using SIGKILL (uncatchable). * @@ -946,9 +961,16 @@ function isConfirmedBootstrapStaleRuntimeDiagnostic(reason?: string): boolean { return text === 'persisted runtime pid is not alive'; } +function isBootstrapProofClearableLaunchFailureReason(reason?: string): boolean { + return ( + isAutoClearableLaunchFailureReason(reason) || isProvisionedButNotAliveFailureReason(reason) + ); +} + function shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(reason?: string): boolean { return ( - isAutoClearableLaunchFailureReason(reason) || isConfirmedBootstrapStaleRuntimeDiagnostic(reason) + isBootstrapProofClearableLaunchFailureReason(reason) || + isConfirmedBootstrapStaleRuntimeDiagnostic(reason) ); } @@ -1077,6 +1099,8 @@ const OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC = 'OpenCode runtime binary is not installed or not reachable by launch preflight.'; const OPENCODE_APP_MCP_UNREACHABLE_DIAGNOSTIC = 'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.'; +const OPENCODE_PENDING_PERMISSION_REQUEST_PATTERN = + /\b(?:pending permission request(?:\(s\)|s)?|permission[_ -]blocked)\b/i; function pushUniqueLine(lines: string[], line: string): void { const trimmed = line.trim(); @@ -1228,6 +1252,7 @@ const STALL_CHECK_INTERVAL_MS = 10_000; const STALL_WARNING_THRESHOLD_MS = 20_000; const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate,mcp__agent-teams__team_launch,mcp__agent-teams__team_stop'; +const CLAUDE_TEAM_RUNTIME_SETTINGS_PATH_ENV = 'CLAUDE_TEAM_RUNTIME_SETTINGS_PATH'; const TEAM_JSON_READ_TIMEOUT_MS = 5_000; const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024; const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024; @@ -1367,6 +1392,8 @@ interface RuntimeStatusCommandResponse { } interface AuthStatusCommandResponse { + provider?: string; + status?: Partial; loggedIn?: boolean; authMethod?: string | null; providers?: Record>; @@ -2174,6 +2201,35 @@ function buildAnthropicCrossProviderDirectAuthEnvPatch( return envPatch; } +const CODEX_CROSS_PROVIDER_SAFE_ENV_KEYS = [ + 'CLAUDE_CODE_CODEX_BACKEND', + 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD', + 'CODEX_CLI_PATH', + 'CODEX_HOME', +] as const; + +function buildCodexCrossProviderSafeEnvPatch(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const envPatch: NodeJS.ProcessEnv = {}; + for (const key of CODEX_CROSS_PROVIDER_SAFE_ENV_KEYS) { + const value = env[key]?.trim(); + if (value) { + envPatch[key] = value; + } + } + return envPatch; +} + +function applyAppManagedRuntimeSettingsPathEnv( + env: NodeJS.ProcessEnv, + settingsPath: string | null +): void { + if (settingsPath) { + env[CLAUDE_TEAM_RUNTIME_SETTINGS_PATH_ENV] = settingsPath; + } else { + delete env[CLAUDE_TEAM_RUNTIME_SETTINGS_PATH_ENV]; + } +} + interface TeamRuntimeAuthContext { teamName?: string; authMaterialId?: string; @@ -2195,6 +2251,8 @@ interface TeamRuntimeLaunchArgsPlan { runtimeTurnSettledHookArgs: string[]; providerArgs: string[]; extraArgs: string[]; + inheritedProviderArgs: string[]; + appManagedSettingsPath: string | null; } type WorkspaceTrustProviderArgsResolver = (input: { @@ -3138,6 +3196,73 @@ function normalizeSameTeamText(text: string): string { return text.trim().replace(/\r\n/g, '\n'); } +const SUPPRESSED_LEAD_RELAY_STATE_PHRASES = [ + 'open', + 'closed', + 'merged', + 'approved', + 'complete', + 'completed', + 'done', + 'blocked', + 'pending', + 'in_progress', + 'in progress', + 'needsfix', + 'needs fix', + 'in review', + 'clear', +] as const; + +function startsWithSuppressedLeadRelayStatePhrase(text: string): boolean { + const lowerText = text.toLowerCase(); + return SUPPRESSED_LEAD_RELAY_STATE_PHRASES.some((phrase) => { + if (!lowerText.startsWith(phrase)) { + return false; + } + + const nextChar = lowerText.charAt(phrase.length); + return nextChar.length === 0 || !/[a-z0-9_]/i.test(nextChar); + }); +} + +function hasSuppressedLeadRelayStatePredicate(normalized: string): boolean { + const match = /\b(?:is|are|was|were|stays?|still|now)\s+/i.exec(normalized); + if (!match) { + return false; + } + + return startsWithSuppressedLeadRelayStatePhrase(normalized.slice(match.index + match[0].length)); +} + +function shouldSuppressUnverifiedLeadRelayStateLine(text: string): boolean { + const normalized = text.trim().replace(/\s+/g, ' '); + if (normalized.length === 0) { + return false; + } + + const hasStateSubject = + /#[a-z0-9]{4,}/i.test(normalized) || + /\bpr\s*#?\d+\b/i.test(normalized) || + /\bpull request\b/i.test(normalized) || + /\b(?:task|tasks|kanban|board|review|approval|merge|merged|branch|queue|worktree|commit|mergecommit|mergedat)\b/i.test( + normalized + ); + if (!hasStateSubject) { + return false; + } + + return ( + /\b(?:confirmed|verified|already|claims?|false|phantom|ground[- ]truth)\b/i.test(normalized) || + /\b(?:done|complete(?:d)?|approved|merged|closed|blocked|resolved|failed|succeeded)\b/i.test( + normalized + ) || + hasSuppressedLeadRelayStatePredicate(normalized) || + /\b(?:mergecommit|mergedat)\s*=\s*(?:null|[^\s,;]+)/i.test(normalized) || + /\bqueue\b.*\bclear\b/i.test(normalized) + ); +} + function getOpenCodeInboxRelayPriority( message: Pick ): number { @@ -4127,6 +4252,7 @@ export class TeamProvisioningService { launchIdentity?: ProviderModelLaunchIdentity | null; envResolution: ProvisioningEnvResolution; extraArgs?: string[]; + inheritedProviderArgs?: string[]; includeAnthropicHelper: boolean; contextLabel: string; }): Promise { @@ -4137,6 +4263,7 @@ export class TeamProvisioningService { : null; const rawProviderArgs = input.envResolution.providerArgs ?? []; const rawExtraArgs = input.extraArgs ?? []; + const rawInheritedProviderArgs = input.inheritedProviderArgs ?? []; if (!helper && resolvedProviderId !== 'anthropic') { return { @@ -4146,6 +4273,8 @@ export class TeamProvisioningService { await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId), providerArgs: rawProviderArgs, extraArgs: rawExtraArgs, + inheritedProviderArgs: rawInheritedProviderArgs, + appManagedSettingsPath: null, }; } @@ -4155,15 +4284,29 @@ export class TeamProvisioningService { ); const splitProviderArgs = splitSettingsJsonArgs(providerArgsWithoutHelper); const splitExtraArgs = splitSettingsJsonArgs(rawExtraArgs); + const splitInheritedArgs = splitSettingsJsonArgs(rawInheritedProviderArgs); + const shouldCoalesceInheritedSettings = splitInheritedArgs.settingsFragments.length > 0; if ( helper && (hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) || - hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs)) + hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs) || + hasPathBasedSettingsArgs(splitInheritedArgs.passthroughArgs)) ) { throw new Error( `${input.contextLabel}: app-managed Anthropic API-key helper cannot be combined with path-based --settings. Use inline JSON settings or remove the custom --settings path.` ); } + if ( + shouldCoalesceInheritedSettings && + !helper && + (hasPathBasedSettingsArgs(splitProviderArgs.passthroughArgs) || + hasPathBasedSettingsArgs(splitExtraArgs.passthroughArgs) || + hasPathBasedSettingsArgs(splitInheritedArgs.passthroughArgs)) + ) { + throw new Error( + `${input.contextLabel}: mixed-provider launch cannot combine app-managed inherited settings with path-based --settings. Use inline JSON settings or remove the custom --settings path.` + ); + } const settingsBundle = await materializeTeamRuntimeSettingsBundle({ teamName: input.teamName, @@ -4173,6 +4316,7 @@ export class TeamProvisioningService { await this.buildRuntimeTurnSettledHookSettingsObject(resolvedProviderId), ...splitProviderArgs.settingsFragments, ...splitExtraArgs.settingsFragments, + ...splitInheritedArgs.settingsFragments, ], anthropicHelper: helper, settingsDirectory: helper ? null : buildRuntimeSettingsTempDirectory(input.teamName), @@ -4184,6 +4328,8 @@ export class TeamProvisioningService { runtimeTurnSettledHookArgs: [], providerArgs: splitProviderArgs.passthroughArgs, extraArgs: splitExtraArgs.passthroughArgs, + inheritedProviderArgs: splitInheritedArgs.passthroughArgs, + appManagedSettingsPath: settingsBundle?.settingsPath ?? null, }; } @@ -4266,7 +4412,7 @@ export class TeamProvisioningService { { cwd: params.cwd, env: params.env, - timeout: 8_000, + timeout: PROVIDER_RUNTIME_STATUS_TIMEOUT_MS, } ) : null; @@ -5031,6 +5177,14 @@ export class TeamProvisioningService { return adapter as OpenCodeRuntimeMessageAdapter; } + private getOpenCodeRuntimePermissionListingAdapter(): OpenCodeRuntimePermissionListingAdapter | null { + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter || typeof adapter.listRuntimePermissions !== 'function') { + return null; + } + return adapter as OpenCodeRuntimePermissionListingAdapter; + } + private resolveRuntimeRecipientProviderIdFromSources( memberName: string, config: TeamConfig | null | undefined, @@ -6261,6 +6415,19 @@ export class TeamProvisioningService { const reason = `opencode_direct_user_delivery_inline_observe_failed: ${getErrorMessage( error )}`; + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName: input.teamName, + runId: input.runtimeRunId, + laneId: input.laneId, + memberName: input.memberName, + cwd: input.cwd, + sessionId: ledgerRecord.runtimeSessionId, + reason, + diagnostics: [ + `opencode_direct_user_delivery_inline_observe_attempt_${inlineObserveAttempt}`, + reason, + ], + }); ledgerRecord = await input.ledger.applyObservation({ id: ledgerRecord.id, responseObservation: { @@ -6294,6 +6461,17 @@ export class TeamProvisioningService { const observedResponse = this.normalizeOpenCodeDeliveryResponseObservation( observed.responseObservation ); + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName: input.teamName, + runId: input.runtimeRunId, + laneId: input.laneId, + memberName: input.memberName, + cwd: input.cwd, + sessionId: observed.sessionId, + responseState: observedResponse?.state, + reason: observedResponse?.reason ?? observed.diagnostics[0], + diagnostics: observed.diagnostics, + }); const hadMessageSendToolError = this.hasOpenCodeObservedMessageSendToolCall(ledgerRecord); ledgerRecord = await input.ledger.applyObservation({ id: ledgerRecord.id, @@ -6773,15 +6951,16 @@ export class TeamProvisioningService { try { const changed = await this.enqueueLaunchStateStoreOperation(input.teamName, async () => { const previous = await this.launchStateStore.read(input.teamName).catch(() => null); - const directMember = previous?.members[input.memberName]; - const laneMemberEntry = Object.entries(previous?.members ?? {}).find( - ([, member]) => member.laneId === input.laneId - ); - const previousMember = directMember ?? laneMemberEntry?.[1]; - const previousMemberKey = directMember ? input.memberName : laneMemberEntry?.[0]; - if (!previous || !previousMember) { + const previousEntry = this.findPersistedLaunchMemberForLane({ + previousLaunchState: previous, + laneId: input.laneId, + memberName: input.memberName, + runId: input.runId, + }); + if (!previous || !previousEntry) { return false; } + const previousMember = previousEntry.member; if (!isPersistedOpenCodeSecondaryLaneMember(previousMember)) { return false; } @@ -6831,7 +7010,7 @@ export class TeamProvisioningService { launchPhase: previous.launchPhase, members: { ...previous.members, - [previousMemberKey ?? previousMember.name]: nextMember, + [previousEntry.key]: nextMember, }, updatedAt: observedAt, }); @@ -6854,6 +7033,617 @@ export class TeamProvisioningService { } } + private hasOpenCodePendingPermissionSignal(input: { + responseState?: OpenCodeMemberInboxDelivery['responseState']; + reason?: string | null; + diagnostics?: readonly string[]; + }): boolean { + if (input.responseState === 'permission_blocked') { + return true; + } + const text = [input.reason ?? undefined, ...(input.diagnostics ?? [])] + .filter((value): value is string => Boolean(value?.trim())) + .join('\n'); + return OPENCODE_PENDING_PERMISSION_REQUEST_PATTERN.test(text); + } + + private findPersistedLaunchMemberForLane(input: { + previousLaunchState: PersistedTeamLaunchSnapshot | null | undefined; + laneId: string; + memberName: string; + runId?: string | null; + }): { key: string; member: PersistedTeamLaunchMemberState } | null { + const members = input.previousLaunchState?.members; + if (!members) { + return null; + } + const laneId = input.laneId.trim() || 'primary'; + const memberName = input.memberName.trim(); + const runId = input.runId?.trim(); + const candidates = Object.entries(members).filter(([key, member]) => { + const storedName = this.resolvePersistedLaunchMemberDisplayName(key, member); + if (storedName !== memberName) { + return false; + } + if ((member.laneId?.trim() || 'primary') !== laneId) { + return false; + } + const memberRunId = member.runtimeRunId?.trim(); + return !(runId && memberRunId && memberRunId !== runId); + }); + if (candidates.length === 0) { + return null; + } + const direct = candidates.find(([key]) => key === memberName); + const [key, member] = direct ?? candidates[0]; + return { key, member }; + } + + private resolvePersistedLaunchMemberDisplayName( + key: string, + member: PersistedTeamLaunchMemberState + ): string { + const storedName = member.name?.trim(); + const laneId = member.laneId?.trim(); + const laneMemberName = + (laneId ? this.extractOpenCodeRuntimeLaneMemberName(laneId) : null) ?? + this.extractOpenCodeRuntimeLaneMemberName(key); + if (storedName && storedName !== laneId && storedName !== key.trim()) { + return storedName; + } + return laneMemberName ?? storedName ?? key.trim(); + } + + private async maybeSyncOpenCodeRuntimePermissionsAfterDelivery(input: { + teamName: string; + runId?: string | null; + laneId: string; + memberName: string; + cwd: string; + sessionId?: string | null; + responseState?: OpenCodeMemberInboxDelivery['responseState']; + reason?: string | null; + diagnostics?: readonly string[]; + teamColor?: string; + teamDisplayName?: string; + }): Promise { + if (!input.runId?.trim()) { + return; + } + const runId = input.runId.trim(); + if (this.getTrackedRunId(input.teamName) !== runId) { + return; + } + if (!this.hasOpenCodePendingPermissionSignal(input)) { + return; + } + + const adapter = this.getOpenCodeRuntimePermissionListingAdapter(); + if (!adapter) { + logger.warn( + `[${input.teamName}] OpenCode runtime permission signal observed for ${input.memberName}, but permission listing bridge is unavailable.` + ); + return; + } + + let listed: { permissions: TeamRuntimePendingPermission[]; diagnostics: string[] }; + try { + listed = await adapter.listRuntimePermissions({ + teamName: input.teamName, + laneId: input.laneId, + cwd: input.cwd, + memberName: input.memberName, + sessionId: input.sessionId, + }); + } catch (error) { + logger.warn( + `[${input.teamName}] Failed to list OpenCode runtime permissions for ${input.memberName}: ${getErrorMessage(error)}` + ); + return; + } + + if (this.getTrackedRunId(input.teamName) !== runId) { + return; + } + + const pendingPermissions = listed.permissions.filter((permission) => + this.isOpenCodeRuntimePermissionForDeliveryTarget(input, permission) + ); + if (pendingPermissions.length === 0) { + const listedDiagnostics = listed.diagnostics.length + ? ` Diagnostics: ${listed.diagnostics.join(' | ')}` + : ''; + logger.warn( + `[${input.teamName}] OpenCode runtime permission signal observed for ${input.memberName}, but bridge listed no matching pending permissions.${listedDiagnostics}` + ); + return; + } + + const previousLaunchState = await this.launchStateStore.read(input.teamName).catch(() => null); + if (this.getTrackedRunId(input.teamName) !== runId) { + return; + } + const expectedMembers = this.resolveOpenCodeRuntimePermissionExpectedMembers({ + teamName: input.teamName, + runId, + laneId: input.laneId, + memberName: input.memberName, + cwd: input.cwd, + previousLaunchState, + }); + const permissionsByMember = this.groupOpenCodeRuntimePermissionsByMember({ + permissions: pendingPermissions, + teamName: input.teamName, + laneId: input.laneId, + memberName: input.memberName, + runId, + sessionId: input.sessionId, + expectedMembers, + previousLaunchState, + }); + if (permissionsByMember.size === 0) { + return; + } + + await this.persistOpenCodeRuntimePendingPermissions({ + ...input, + permissionsByMember, + previousLaunchState, + }); + if (this.getTrackedRunId(input.teamName) !== runId) { + return; + } + this.syncOpenCodeRuntimePermissionSpawnStatuses({ + ...input, + permissionsByMember, + }); + + const members: Record = {}; + for (const [memberName, permissions] of permissionsByMember) { + members[memberName] = this.buildOpenCodePermissionPendingEvidence({ + teamName: input.teamName, + laneId: input.laneId, + memberName, + permissions, + runId, + sessionId: input.sessionId, + previousLaunchState, + }); + } + + this.syncOpenCodeRuntimeToolApprovals({ + teamName: input.teamName, + runId: input.runId, + laneId: input.laneId, + cwd: input.cwd, + members, + expectedMembers, + memberNames: Array.from(permissionsByMember.keys()), + teamColor: input.teamColor, + teamDisplayName: input.teamDisplayName, + }); + } + + private isOpenCodeRuntimePermissionForDeliveryTarget( + input: { + laneId: string; + sessionId?: string | null; + }, + permission: TeamRuntimePendingPermission + ): boolean { + const permissionSessionId = permission.sessionId?.trim(); + const inputSessionId = input.sessionId?.trim(); + if (permissionSessionId && inputSessionId) { + return permissionSessionId === inputSessionId; + } + return true; + } + + private resolveOpenCodeRuntimePermissionExpectedMembers(input: { + teamName: string; + runId: string; + laneId: string; + memberName: string; + cwd: string; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + }): TeamRuntimeMemberSpec[] { + const members = new Map(); + for (const [memberKey, member] of Object.entries(input.previousLaunchState?.members ?? {})) { + if (member.providerId !== 'opencode') continue; + if ((member.laneId?.trim() || 'primary') !== input.laneId) continue; + const memberRunId = member.runtimeRunId?.trim(); + if (memberRunId && memberRunId !== input.runId) continue; + const displayName = this.resolvePersistedLaunchMemberDisplayName(memberKey, member); + members.set(displayName, { + name: displayName, + role: undefined, + workflow: undefined, + isolation: undefined, + providerId: 'opencode', + model: member.model, + effort: member.effort, + cwd: member.cwd?.trim() || input.cwd, + }); + } + + const trackedRunId = this.getTrackedRunId(input.teamName); + const trackedRun = trackedRunId ? this.runs.get(trackedRunId) : null; + for (const member of [ + ...(trackedRun?.allEffectiveMembers ?? []), + ...(trackedRun?.effectiveMembers ?? []), + ]) { + if (member.providerId !== 'opencode' || members.has(member.name)) continue; + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId: resolveTeamProviderId(trackedRun?.request.providerId), + member: { + name: member.name, + providerId: 'opencode', + }, + }); + if (laneIdentity.laneId !== input.laneId) continue; + members.set(member.name, { + name: member.name, + role: member.role, + workflow: member.workflow, + isolation: member.isolation === 'worktree' ? 'worktree' : undefined, + providerId: 'opencode', + model: member.model, + effort: member.effort, + cwd: member.cwd?.trim() || input.cwd, + }); + } + const runtimeRun = this.runtimeAdapterRunByTeam.get(input.teamName); + if ( + (input.laneId.trim() || 'primary') === 'primary' && + runtimeRun?.runId === input.runId && + runtimeRun.providerId === 'opencode' + ) { + for (const [memberKey, evidence] of Object.entries(runtimeRun.members ?? {})) { + const memberName = evidence.memberName?.trim() || memberKey; + if (!memberName || members.has(memberName)) continue; + members.set(memberName, { + name: memberName, + providerId: 'opencode', + model: evidence.model, + cwd: input.cwd, + }); + } + } + + if (!members.has(input.memberName)) { + members.set(input.memberName, { + name: input.memberName, + providerId: 'opencode', + cwd: input.cwd, + }); + } + return Array.from(members.values()); + } + + private groupOpenCodeRuntimePermissionsByMember(input: { + permissions: readonly TeamRuntimePendingPermission[]; + teamName: string; + laneId: string; + memberName: string; + runId: string; + sessionId?: string | null; + expectedMembers: readonly TeamRuntimeMemberSpec[]; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + }): Map { + const sessionToMember = new Map(); + for (const [memberName, member] of Object.entries(input.previousLaunchState?.members ?? {})) { + if ((member.laneId?.trim() || 'primary') !== input.laneId) continue; + const memberRunId = member.runtimeRunId?.trim(); + if (memberRunId && memberRunId !== input.runId) continue; + const sessionId = member.runtimeSessionId?.trim(); + if (sessionId) { + sessionToMember.set( + sessionId, + this.resolvePersistedLaunchMemberDisplayName(memberName, member) + ); + } + } + const trackedRunId = this.getTrackedRunId(input.teamName); + const trackedRun = trackedRunId ? this.runs.get(trackedRunId) : null; + const lane = trackedRun?.mixedSecondaryLanes?.find( + (candidate) => candidate.laneId === input.laneId + ); + for (const [memberName, evidence] of Object.entries(lane?.result?.members ?? {})) { + const sessionId = evidence.sessionId?.trim(); + if (sessionId) { + sessionToMember.set(sessionId, evidence.memberName?.trim() || memberName); + } + } + const runtimeRun = this.runtimeAdapterRunByTeam.get(input.teamName); + if ( + (input.laneId.trim() || 'primary') === 'primary' && + runtimeRun?.runId === input.runId && + runtimeRun.providerId === 'opencode' + ) { + for (const [memberName, evidence] of Object.entries(runtimeRun.members ?? {})) { + const sessionId = evidence.sessionId?.trim(); + if (sessionId) { + sessionToMember.set(sessionId, evidence.memberName?.trim() || memberName); + } + } + } + + const singleExpectedMember = + input.expectedMembers.length === 1 ? input.expectedMembers[0]?.name : undefined; + const inputSessionId = input.sessionId?.trim(); + const result = new Map(); + for (const permission of input.permissions) { + const permissionSessionId = permission.sessionId?.trim(); + const memberName = permissionSessionId + ? (sessionToMember.get(permissionSessionId) ?? + (inputSessionId === permissionSessionId ? input.memberName : undefined) ?? + singleExpectedMember) + : (singleExpectedMember ?? input.memberName); + if (!memberName) { + continue; + } + result.set(memberName, [...(result.get(memberName) ?? []), permission]); + } + return result; + } + + private buildOpenCodePermissionPendingEvidence(input: { + teamName: string; + laneId: string; + memberName: string; + permissions: readonly TeamRuntimePendingPermission[]; + runId: string; + sessionId?: string | null; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + }): TeamRuntimeMemberLaunchEvidence { + const previous = this.findPersistedLaunchMemberForLane({ + previousLaunchState: input.previousLaunchState, + laneId: input.laneId, + memberName: input.memberName, + runId: input.runId, + })?.member; + const ids = Array.from(new Set(input.permissions.map((permission) => permission.requestId))); + const sessionId = previous?.runtimeSessionId ?? input.sessionId?.trim() ?? undefined; + return { + memberName: input.memberName, + providerId: 'opencode', + ...(previous?.model ? { model: previous.model } : {}), + launchState: + previous?.launchState === 'confirmed_alive' || previous?.bootstrapConfirmed + ? 'confirmed_alive' + : 'runtime_pending_permission', + agentToolAccepted: previous?.agentToolAccepted ?? true, + runtimeAlive: previous?.runtimeAlive ?? false, + bootstrapConfirmed: previous?.bootstrapConfirmed ?? false, + hardFailure: false, + pendingPermissionRequestIds: ids, + pendingApprovals: [...input.permissions], + pendingPermissions: [...input.permissions], + ...(sessionId ? { sessionId } : {}), + livenessKind: previous?.livenessKind ?? 'permission_blocked', + runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [ + 'OpenCode runtime permission request discovered after delivery was blocked.', + ...(previous?.diagnostics ?? []), + ], + }; + } + + private async persistOpenCodeRuntimePendingPermissions(input: { + teamName: string; + runId?: string | null; + laneId: string; + sessionId?: string | null; + permissionsByMember: ReadonlyMap; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + }): Promise { + if (!input.previousLaunchState) { + return; + } + const observedAt = nowIso(); + try { + const changed = await this.enqueueLaunchStateStoreOperation(input.teamName, async () => { + const incomingRunId = input.runId?.trim(); + if (incomingRunId && this.getTrackedRunId(input.teamName) !== incomingRunId) { + return false; + } + const previous = await this.launchStateStore.read(input.teamName).catch(() => null); + if (!previous) { + return false; + } + let didChange = false; + const members = { ...previous.members }; + for (const [memberName, permissions] of input.permissionsByMember) { + const previousEntry = this.findPersistedLaunchMemberForLane({ + previousLaunchState: previous, + laneId: input.laneId, + memberName, + runId: input.runId, + }); + if (!previousEntry || previousEntry.member.providerId !== 'opencode') { + continue; + } + const previousMember = previousEntry.member; + if ((previousMember.laneId?.trim() || 'primary') !== input.laneId) { + continue; + } + const previousRunId = previousMember.runtimeRunId?.trim(); + if (previousRunId && incomingRunId && previousRunId !== incomingRunId) { + continue; + } + const previousSessionId = previousMember.runtimeSessionId?.trim(); + const incomingSessionId = input.sessionId?.trim(); + if (previousSessionId && incomingSessionId && previousSessionId !== incomingSessionId) { + continue; + } + const pendingPermissionRequestIds = Array.from( + new Set(permissions.map((permission) => permission.requestId.trim()).filter(Boolean)) + ); + const nextMember: PersistedTeamLaunchMemberState = { + ...previousMember, + name: memberName, + launchState: + previousMember.launchState === 'confirmed_alive' || previousMember.bootstrapConfirmed + ? 'confirmed_alive' + : 'runtime_pending_permission', + hardFailure: false, + hardFailureReason: undefined, + pendingPermissionRequestIds, + ...(incomingRunId ? { runtimeRunId: incomingRunId } : {}), + ...(incomingSessionId && !previousSessionId + ? { runtimeSessionId: incomingSessionId } + : {}), + livenessKind: previousMember.livenessKind ?? 'permission_blocked', + runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval', + runtimeDiagnosticSeverity: 'warning', + lastEvaluatedAt: observedAt, + diagnostics: mergeRuntimeDiagnostics( + previousMember.diagnostics, + ['waiting for permission approval'], + previousMember.runtimeDiagnostic + ), + }; + if ( + previousMember.name === nextMember.name && + previousMember.launchState === nextMember.launchState && + previousMember.hardFailure === nextMember.hardFailure && + previousMember.hardFailureReason === nextMember.hardFailureReason && + previousMember.pendingPermissionRequestIds?.join('\0') === + nextMember.pendingPermissionRequestIds?.join('\0') && + previousMember.runtimeRunId === nextMember.runtimeRunId && + previousMember.runtimeSessionId === nextMember.runtimeSessionId && + previousMember.livenessKind === nextMember.livenessKind && + previousMember.runtimeDiagnostic === nextMember.runtimeDiagnostic && + previousMember.runtimeDiagnosticSeverity === nextMember.runtimeDiagnosticSeverity + ) { + continue; + } + members[previousEntry.key] = nextMember; + didChange = true; + } + if (!didChange) { + return false; + } + const nextSnapshot = createPersistedLaunchSnapshot({ + teamName: previous.teamName, + expectedMembers: previous.expectedMembers, + bootstrapExpectedMembers: previous.bootstrapExpectedMembers, + leadSessionId: previous.leadSessionId, + launchPhase: previous.launchPhase, + members, + updatedAt: observedAt, + }); + await this.writeLaunchStateSnapshotNow(input.teamName, nextSnapshot); + return true; + }); + if (changed) { + this.invalidateRuntimeSnapshotCaches(input.teamName); + for (const memberName of input.permissionsByMember.keys()) { + this.teamChangeEmitter?.({ + type: 'member-spawn', + teamName: input.teamName, + ...(input.runId ? { runId: input.runId } : {}), + detail: memberName, + }); + } + } + } catch (error) { + logger.debug( + `[${input.teamName}] Failed to persist OpenCode pending runtime permissions: ${getErrorMessage(error)}` + ); + } + } + + private syncOpenCodeRuntimePermissionSpawnStatuses(input: { + teamName: string; + runId?: string | null; + laneId: string; + permissionsByMember: ReadonlyMap; + }): void { + const trackedRunId = this.getTrackedRunId(input.teamName); + const run = trackedRunId ? this.runs.get(trackedRunId) : null; + if (!run || run.runId !== input.runId) { + return; + } + const updatedAt = nowIso(); + for (const [memberName, permissions] of input.permissionsByMember) { + const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry(); + const lane = run.mixedSecondaryLanes?.find((candidate) => candidate.laneId === input.laneId); + const laneEvidence = lane?.result?.members?.[memberName]; + const pendingPermissionRequestIds = Array.from( + new Set(permissions.map((permission) => permission.requestId.trim()).filter(Boolean)) + ); + const joinedPendingPermissionRequestIds = pendingPermissionRequestIds.join('\0'); + const laneEvidenceNeedsUpdate = Boolean( + lane?.result && + laneEvidence && + (laneEvidence.pendingPermissionRequestIds?.join('\0') !== + joinedPendingPermissionRequestIds || + laneEvidence.runtimeDiagnostic !== + 'OpenCode runtime is waiting for permission approval' || + laneEvidence.runtimeDiagnosticSeverity !== 'warning') + ); + const next: MemberSpawnStatusEntry = { + ...prev, + status: prev.bootstrapConfirmed || laneEvidence?.bootstrapConfirmed ? 'online' : 'waiting', + launchState: prev.launchState, + agentToolAccepted: true, + runtimeAlive: prev.runtimeAlive === true || laneEvidence?.runtimeAlive === true, + bootstrapConfirmed: + prev.bootstrapConfirmed === true || laneEvidence?.bootstrapConfirmed === true, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + pendingPermissionRequestIds, + livenessKind: prev.livenessKind ?? laneEvidence?.livenessKind ?? 'permission_blocked', + runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval', + runtimeDiagnosticSeverity: 'warning', + updatedAt, + }; + next.launchState = deriveMemberLaunchState(next); + if ( + prev.pendingPermissionRequestIds?.join('\0') === joinedPendingPermissionRequestIds && + prev.launchState === next.launchState && + prev.runtimeDiagnostic === next.runtimeDiagnostic && + !laneEvidenceNeedsUpdate + ) { + continue; + } + run.memberSpawnStatuses.set(memberName, next); + if (lane?.result && laneEvidence) { + lane.result = { + ...lane.result, + members: { + ...lane.result.members, + [memberName]: { + ...laneEvidence, + hardFailure: false, + hardFailureReason: undefined, + pendingPermissionRequestIds, + pendingApprovals: [...permissions], + pendingPermissions: [...permissions], + runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval', + runtimeDiagnosticSeverity: 'warning', + diagnostics: + mergeRuntimeDiagnostics( + laneEvidence.diagnostics, + ['waiting for permission approval'], + laneEvidence.runtimeDiagnostic + ) ?? [], + }, + }, + }; + } + if (this.isCurrentTrackedRun(run)) { + this.emitMemberSpawnChange(run, memberName); + } + } + if (run.isLaunch) { + void this.persistLaunchStateSnapshot(run, run.provisioningComplete ? 'finished' : 'active'); + } + } + private logOpenCodePromptDeliveryEvent( event: string, record: OpenCodePromptDeliveryLedgerRecord, @@ -7678,6 +8468,19 @@ export class TeamProvisioningService { const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( result.responseObservation ); + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName, + runId: runtimeRunId, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + cwd, + sessionId: result.sessionId, + responseState: responseObservation?.state, + reason: responseObservation?.reason ?? result.diagnostics[0], + diagnostics: result.diagnostics, + teamColor: config?.color, + teamDisplayName: config?.name, + }); return { delivered: result.ok, accepted: result.ok, @@ -7950,6 +8753,19 @@ export class TeamProvisioningService { const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( observed.responseObservation ); + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName, + runId: runtimeRunId, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + cwd, + sessionId: observed.sessionId, + responseState: responseObservation?.state, + reason: responseObservation?.reason ?? observed.diagnostics[0], + diagnostics: observed.diagnostics, + teamColor: config?.color, + teamDisplayName: config?.name, + }); ledgerRecord = await ledger.applyObservation({ id: ledgerRecord.id, responseObservation: responseObservation ?? { @@ -8176,6 +8992,17 @@ export class TeamProvisioningService { }); } catch (error) { const diagnostic = `opencode_message_delivery_exception: ${getErrorMessage(error)}`; + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName, + runId: runtimeRunId, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + cwd, + reason: diagnostic, + diagnostics: [diagnostic], + teamColor: config?.color, + teamDisplayName: config?.name, + }); if (ledgerRecord && ledger) { ledgerRecord = await ledger.applyDeliveryResult({ id: ledgerRecord.id, @@ -8256,6 +9083,19 @@ export class TeamProvisioningService { const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( result.responseObservation ); + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName, + runId: runtimeRunId, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + cwd, + sessionId: result.sessionId, + responseState: responseObservation?.state, + reason: responseObservation?.reason ?? result.diagnostics[0], + diagnostics: result.diagnostics, + teamColor: config?.color, + teamDisplayName: config?.name, + }); const promptAcceptedByRuntimeIdentity = Boolean( result.ok && result.runtimePromptMessageId?.trim() ); @@ -12890,13 +13730,14 @@ export class TeamProvisioningService { status: 'online', updatedAt, agentToolAccepted: true, - runtimeAlive: source === 'runtime-proof' ? true : prev.runtimeAlive === true, + runtimeAlive: source === 'runtime-proof' ? true : prev.runtimeAlive, bootstrapConfirmed: true, hardFailure: false, bootstrapStalled: undefined, error: undefined, hardFailureReason: undefined, - livenessSource: prev.livenessSource ?? 'process', + livenessSource: + source === 'runtime-proof' ? (prev.livenessSource ?? 'process') : prev.livenessSource, firstSpawnAcceptedAt: prev.firstSpawnAcceptedAt ?? observedAt, lastHeartbeatAt: isMemberSpawnHeartbeatTimestampNewer(prev.lastHeartbeatAt, observedAt) ? observedAt @@ -13995,6 +14836,10 @@ export class TeamProvisioningService { includeAnthropicHelper: providerId === 'anthropic', contextLabel: `Direct teammate restart (${input.configuredMember.name})`, }); + applyAppManagedRuntimeSettingsPathEnv( + provisioningEnv.env, + runtimeArgsPlan.appManagedSettingsPath + ); const runtimeArgs = mergeJsonSettingsArgs([ '--agent-id', @@ -14186,6 +15031,10 @@ export class TeamProvisioningService { includeAnthropicHelper: providerId === 'anthropic', contextLabel: `Direct process teammate restart (${input.configuredMember.name})`, }); + applyAppManagedRuntimeSettingsPathEnv( + provisioningEnv.env, + runtimeArgsPlan.appManagedSettingsPath + ); const runtimeArgs = mergeJsonSettingsArgs([ '--teammate-runtime', @@ -16352,7 +17201,7 @@ export class TeamProvisioningService { const canClearFailedBootstrap = current?.launchState === 'failed_to_start' && current.agentToolAccepted === true && - isAutoClearableLaunchFailureReason(failureReason); + isBootstrapProofClearableLaunchFailureReason(failureReason); if ( !current || (current.launchState === 'failed_to_start' && !canClearFailedBootstrap) || @@ -19395,6 +20244,7 @@ export class TeamProvisioningService { launchIdentity, envResolution: { ...provisioningEnv, providerArgs: providerArgsForLaunch }, extraArgs: extraCliArgs, + inheritedProviderArgs: crossProviderMemberArgsForLaunch.args, includeAnthropicHelper: resolvedProviderId === 'anthropic', contextLabel: 'Team create launch', }); @@ -19430,8 +20280,9 @@ export class TeamProvisioningService { ...runtimeArgsPlan.extraArgs, ...runtimeArgsPlan.providerArgs, ...runtimeArgsPlan.settingsArgs, - ...crossProviderMemberArgsForLaunch.args, + ...runtimeArgsPlan.inheritedProviderArgs, ]); + applyAppManagedRuntimeSettingsPathEnv(shellEnv, runtimeArgsPlan.appManagedSettingsPath); const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, promptSize, @@ -20723,6 +21574,7 @@ export class TeamProvisioningService { launchIdentity, envResolution: { ...provisioningEnv, providerArgs: providerArgsForLaunch }, extraArgs: extraCliArgs, + inheritedProviderArgs: crossProviderMemberArgsForLaunch.args, includeAnthropicHelper: resolvedProviderId === 'anthropic', contextLabel: 'Team launch', }); @@ -20747,8 +21599,9 @@ export class TeamProvisioningService { // Without this, a codex teammate spawned from an anthropic lead has no way to learn // about the required forced_login_method (chatgpt/api) and fails to start. emitProvisioningCheckpoint(run, 'Resolving cross-provider member launch args'); - launchArgs.push(...crossProviderMemberArgsForLaunch.args); + launchArgs.push(...runtimeArgsPlan.inheritedProviderArgs); const finalLaunchArgs = mergeJsonSettingsArgs(launchArgs); + applyAppManagedRuntimeSettingsPathEnv(shellEnv, runtimeArgsPlan.appManagedSettingsPath); const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, promptSize, @@ -22624,6 +23477,7 @@ export class TeamProvisioningService { `Plain text reply visibility for this batch: internal lead activity only.`, `Do NOT write a user-facing summary for teammate/system/cross-team relay traffic. If the human user must be notified, explicitly call SendMessage with recipient "user".`, `If you take action and no visible message/tool result already records it, you may write one terse internal status line for the team activity log.`, + `Do not use that internal status line to confirm, correct, or relay task, kanban, review, PR, branch, merge, or queue state unless you verified it with the source-of-truth tool in this turn.`, `If a visible reply is needed for a teammate, another team, or the human user, use the appropriate messaging tool instead of relying on plain text.`, ]; @@ -22640,6 +23494,7 @@ export class TeamProvisioningService { [ `Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`, `For any MCP board tool call in this turn, teamName MUST be "${teamName}". Never use the lead/member name "${leadName}" as teamName.`, + `Treat teammate/system/cross-team claims about task, kanban, review, PR, branch, merge, or queue state as unverified until checked. Before confirming, correcting, relaying, or acting on that state, call the relevant source-of-truth tool first (task_get/task_list/review/kanban tooling, or an available repository/GitHub command/tool). If you have not verified it in this turn, say verification is needed instead of stating the claim as fact.`, `A member_work_sync_status call alone is incomplete for Message kind: member_work_sync_nudge. Do not stop until member_work_sync_report succeeds or a real blocker is recorded.`, `Use task_create_from_message only for messages below that explicitly say "Eligible for task_create_from_message: yes" and provide a User MessageId. Never use task_create_from_message for teammate messages, system notifications, cross-team messages, or any inbox row that is not explicitly marked eligible.`, `If a message below is marked Source: system_notification and its summary looks like "Comment on #...", reply via task_add_comment only when you have a substantive board update (decision, blocker, clarification answer, review result, or concrete next-step change).`, @@ -22808,6 +23663,11 @@ export class TeamProvisioningService { (replyVisibility === 'user' && capturedUserVisibleSendMessage) ) { logger.debug(`[${teamName}] Suppressed lead relay text duplicated by visible message`); + } else if ( + replyVisibility === 'internal_activity' && + shouldSuppressUnverifiedLeadRelayStateLine(cleanReply) + ) { + logger.debug(`[${teamName}] Suppressed unverified lead relay state claim`); } else if (replyVisibility === 'internal_activity') { this.pushLiveLeadTextMessage( run, @@ -23460,6 +24320,39 @@ export class TeamProvisioningService { current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive'; const shouldSuppressWeakRuntimeMetadataForConfirmedBootstrap = hasConfirmedBootstrap && !hasStrongEvidence; + const failureReason = current.hardFailureReason ?? current.error ?? current.runtimeDiagnostic; + const bootstrapProofClearableFailure = + isBootstrapProofClearableLaunchFailureReason(failureReason); + const metadataRuntimeDiagnosticForUnsafe = buildRuntimeDiagnosticForSpawn(metadata); + const unsafeRuntimeDiagnosticEvidence = + metadataRuntimeDiagnosticForUnsafe && + current.runtimeDiagnostic && + metadataRuntimeDiagnosticForUnsafe !== current.runtimeDiagnostic + ? `${metadataRuntimeDiagnosticForUnsafe}; ${current.runtimeDiagnostic}` + : (metadataRuntimeDiagnosticForUnsafe ?? current.runtimeDiagnostic); + const hasUnsafeProvisionedButNotAliveFailure = + isProvisionedButNotAliveFailureReason(failureReason) && + hasUnsafeProvisionedButNotAliveRuntimeEvidence({ + ...current, + runtimeDiagnostic: unsafeRuntimeDiagnosticEvidence, + runtimeDiagnosticSeverity: + metadata.runtimeDiagnosticSeverity ?? current.runtimeDiagnosticSeverity, + livenessKind: metadata.livenessKind ?? current.livenessKind, + }); + const shouldPreserveConfirmedBootstrapRuntimeError = + hasConfirmedBootstrap && + metadata.alive === false && + metadata.runtimeDiagnosticSeverity === 'error'; + const shouldPreserveUnsafeMetadataLivenessKind = + hasUnsafeProvisionedButNotAliveFailure && + (metadata.livenessKind === 'not_found' || + metadata.livenessKind === 'shell_only' || + metadata.livenessKind === 'runtime_process_candidate' || + ((metadata.livenessKind === 'registered_only' || + metadata.livenessKind === 'stale_metadata') && + (metadata.runtimeDiagnosticSeverity ?? current.runtimeDiagnosticSeverity) !== 'error' && + !mentionsProcessTableUnavailable(unsafeRuntimeDiagnosticEvidence) && + !mentionsProcessTableUnavailable(failureReason))); let runtimeDiagnostic: string | undefined; let runtimeDiagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity | undefined; if (shouldPreserveProcessBootstrapTransportDiagnostic) { @@ -23473,7 +24366,7 @@ export class TeamProvisioningService { runtimeDiagnostic = current.runtimeDiagnostic; runtimeDiagnosticSeverity = current.runtimeDiagnosticSeverity; } else { - const metadataRuntimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata); + const metadataRuntimeDiagnostic = metadataRuntimeDiagnosticForUnsafe; if ( metadataRuntimeDiagnostic && !shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(metadataRuntimeDiagnostic) @@ -23488,7 +24381,9 @@ export class TeamProvisioningService { } const metadataLivenessKind = hasConfirmedBootstrap ? metadata.livenessKind === 'runtime_process' || - metadata.livenessKind === 'confirmed_bootstrap' + metadata.livenessKind === 'confirmed_bootstrap' || + shouldPreserveConfirmedBootstrapRuntimeError || + shouldPreserveUnsafeMetadataLivenessKind ? metadata.livenessKind : current.livenessKind === 'stale_metadata' || current.livenessKind === 'registered_only' ? 'confirmed_bootstrap' @@ -23508,7 +24403,6 @@ export class TeamProvisioningService { : {}), livenessLastCheckedAt: nowIso(), }; - const failureReason = current.hardFailureReason ?? current.error; const hasWeakEvidence = metadata.livenessKind != null && !hasStrongEvidence && current.bootstrapConfirmed !== true; if ( @@ -23552,7 +24446,8 @@ export class TeamProvisioningService { if ( hasStrongEvidence && current.launchState === 'failed_to_start' && - isAutoClearableLaunchFailureReason(failureReason) + bootstrapProofClearableFailure && + !hasUnsafeProvisionedButNotAliveFailure ) { nextEntry.status = 'online'; nextEntry.agentToolAccepted = true; @@ -23563,7 +24458,34 @@ export class TeamProvisioningService { nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process'; nextEntry.launchState = deriveMemberLaunchState(nextEntry); } - if (hasWeakEvidence) { + if ( + hasConfirmedBootstrap && + current.hardFailure === true && + bootstrapProofClearableFailure && + !hasUnsafeProvisionedButNotAliveFailure + ) { + nextEntry.status = 'online'; + nextEntry.agentToolAccepted = true; + nextEntry.runtimeAlive = true; + nextEntry.bootstrapConfirmed = true; + nextEntry.hardFailure = false; + nextEntry.hardFailureReason = undefined; + nextEntry.error = undefined; + nextEntry.bootstrapStalled = undefined; + nextEntry.launchState = deriveMemberLaunchState(nextEntry); + } + const healedConfirmedBootstrapFailure = + hasConfirmedBootstrap && + current.hardFailure === true && + bootstrapProofClearableFailure && + !hasUnsafeProvisionedButNotAliveFailure; + if (shouldPreserveConfirmedBootstrapRuntimeError) { + nextEntry.runtimeAlive = false; + if (nextEntry.livenessSource === 'process') { + nextEntry.livenessSource = undefined; + } + } + if (hasWeakEvidence && !healedConfirmedBootstrapFailure) { nextEntry.runtimeAlive = false; if (nextEntry.livenessSource === 'process') { nextEntry.livenessSource = undefined; @@ -25931,10 +26853,17 @@ export class TeamProvisioningService { if (current.launchState === 'skipped_for_launch' || current.skippedForLaunch === true) { continue; } - const failureReason = current.hardFailureReason ?? current.error; + const failureReason = current.hardFailureReason ?? current.error ?? current.runtimeDiagnostic; + const provisionedButNotAliveFailure = isProvisionedButNotAliveFailureReason(failureReason); + if ( + provisionedButNotAliveFailure && + hasUnsafeProvisionedButNotAliveRuntimeEvidence(current) + ) { + continue; + } if ( current.launchState === 'failed_to_start' && - !isAutoClearableLaunchFailureReason(failureReason) + !isBootstrapProofClearableLaunchFailureReason(failureReason) ) { continue; } @@ -26044,12 +26973,19 @@ export class TeamProvisioningService { : undefined; const failureReason = current.hardFailureReason ?? persistedError ?? current.runtimeDiagnostic; + const provisionedButNotAliveFailure = isProvisionedButNotAliveFailureReason(failureReason); + if ( + provisionedButNotAliveFailure && + hasUnsafeProvisionedButNotAliveRuntimeEvidence(current) + ) { + continue; + } const hasFailure = current.launchState === 'failed_to_start' || current.hardFailure === true || typeof current.hardFailureReason === 'string' || typeof persistedError === 'string'; - if (hasFailure && !isAutoClearableLaunchFailureReason(failureReason)) { + if (hasFailure && !isBootstrapProofClearableLaunchFailureReason(failureReason)) { continue; } @@ -26062,14 +26998,21 @@ export class TeamProvisioningService { ...current, launchState: 'confirmed_alive', agentToolAccepted: true, - runtimeAlive: current.runtimeAlive === true || bootstrapMember.runtimeAlive === true, + runtimeAlive: + current.runtimeAlive === true || + bootstrapMember.runtimeAlive === true || + provisionedButNotAliveFailure, bootstrapConfirmed: true, hardFailure: false, hardFailureReason: undefined, - runtimeDiagnostic: isAutoClearableLaunchFailureReason(current.runtimeDiagnostic) + runtimeDiagnostic: shouldClearRuntimeDiagnosticAfterBootstrapConfirmation( + current.runtimeDiagnostic + ) ? undefined : current.runtimeDiagnostic, - runtimeDiagnosticSeverity: isAutoClearableLaunchFailureReason(current.runtimeDiagnostic) + runtimeDiagnosticSeverity: shouldClearRuntimeDiagnosticAfterBootstrapConfirmation( + current.runtimeDiagnostic + ) ? undefined : current.runtimeDiagnosticSeverity, bootstrapStalled: undefined, @@ -27855,7 +28798,9 @@ export class TeamProvisioningService { current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; if ( current.launchState !== 'failed_to_start' || - isAutoClearableLaunchFailureReason(current.hardFailureReason ?? current.runtimeDiagnostic) + isBootstrapProofClearableLaunchFailureReason( + current.hardFailureReason ?? current.runtimeDiagnostic + ) ) { const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt( snapshot.teamName, @@ -28270,9 +29215,16 @@ export class TeamProvisioningService { continue; } const failureReason = current.hardFailureReason ?? current.runtimeDiagnostic; + const provisionedButNotAliveFailure = isProvisionedButNotAliveFailureReason(failureReason); + if ( + provisionedButNotAliveFailure && + hasUnsafeProvisionedButNotAliveRuntimeEvidence(current) + ) { + continue; + } const canClearFailedBootstrap = current.launchState !== 'failed_to_start' || - isAutoClearableLaunchFailureReason(failureReason); + isBootstrapProofClearableLaunchFailureReason(failureReason); if (!canClearFailedBootstrap) { continue; } @@ -28300,7 +29252,9 @@ export class TeamProvisioningService { ...current, agentToolAccepted: true, bootstrapConfirmed: true, - runtimeAlive: runtimeProofObservedAt ? true : current.runtimeAlive === true, + runtimeAlive: runtimeProofObservedAt + ? true + : current.runtimeAlive === true || provisionedButNotAliveFailure, hardFailure: false, hardFailureReason: undefined, lastHeartbeatAt: current.lastHeartbeatAt ?? observedAt, @@ -28357,7 +29311,7 @@ export class TeamProvisioningService { const failureReason = current.hardFailureReason ?? current.runtimeDiagnostic; const hasAutoClearableFailure = (current.launchState === 'failed_to_start' || current.hardFailure === true) && - isAutoClearableLaunchFailureReason(failureReason); + isBootstrapProofClearableLaunchFailureReason(failureReason); if (!currentConfirmed || hasAutoClearableFailure) { return true; } @@ -28752,12 +29706,60 @@ export class TeamProvisioningService { current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; const initialFailureReason = current.hardFailureReason ?? current.runtimeDiagnostic; const hadAutoClearableFailure = isAutoClearableLaunchFailureReason(initialFailureReason); + const requiresConfirmedBootstrapToClearFailure = + isCliProvisionedButNotAliveFailureReason(initialFailureReason); + const metadataRuntimeDiagnostic = runtimeMetadata?.[1].runtimeDiagnostic; + const metadataRuntimeDiagnosticSeverity = runtimeMetadata?.[1].runtimeDiagnosticSeverity; + const metadataLivenessKind = runtimeMetadata?.[1].livenessKind; + const refreshedRuntimeDiagnosticEvidence = + metadataRuntimeDiagnostic && + current.runtimeDiagnostic && + metadataRuntimeDiagnostic !== current.runtimeDiagnostic + ? `${metadataRuntimeDiagnostic}; ${current.runtimeDiagnostic}` + : (metadataRuntimeDiagnostic ?? current.runtimeDiagnostic); + const hasUnsafeProvisionedButNotAliveFailure = + requiresConfirmedBootstrapToClearFailure && + hasUnsafeProvisionedButNotAliveRuntimeEvidence({ + ...current, + runtimeDiagnostic: refreshedRuntimeDiagnosticEvidence, + runtimeDiagnosticSeverity: + metadataRuntimeDiagnosticSeverity ?? current.runtimeDiagnosticSeverity, + livenessKind: metadataLivenessKind ?? current.livenessKind, + }); + const shouldPreserveUnsafeMetadataLivenessKind = + hasUnsafeProvisionedButNotAliveFailure && + (metadataLivenessKind === 'not_found' || + metadataLivenessKind === 'shell_only' || + metadataLivenessKind === 'runtime_process_candidate' || + ((metadataLivenessKind === 'registered_only' || + metadataLivenessKind === 'stale_metadata') && + (metadataRuntimeDiagnosticSeverity ?? current.runtimeDiagnosticSeverity) !== 'error' && + !mentionsProcessTableUnavailable(refreshedRuntimeDiagnosticEvidence) && + !mentionsProcessTableUnavailable(initialFailureReason))); + const nextLivenessKind = current.bootstrapConfirmed + ? metadataLivenessKind === 'runtime_process' || + metadataLivenessKind === 'confirmed_bootstrap' || + shouldPreserveUnsafeMetadataLivenessKind + ? metadataLivenessKind + : current.livenessKind === 'stale_metadata' || current.livenessKind === 'registered_only' + ? 'confirmed_bootstrap' + : (current.livenessKind ?? 'confirmed_bootstrap') + : (metadataLivenessKind ?? current.livenessKind); current.runtimeAlive = observedRuntimeAlive; current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt; - current.livenessKind = runtimeMetadata?.[1].livenessKind; + current.livenessKind = nextLivenessKind; current.pidSource = runtimeMetadata?.[1].pidSource; - current.runtimeDiagnostic = runtimeMetadata?.[1].runtimeDiagnostic; - current.runtimeDiagnosticSeverity = runtimeMetadata?.[1].runtimeDiagnosticSeverity; + const shouldKeepUnsafeRuntimeDiagnostic = + hasUnsafeProvisionedButNotAliveFailure && + (metadataRuntimeDiagnostic == null || + (current.runtimeDiagnosticSeverity === 'error' && + metadataRuntimeDiagnosticSeverity !== 'error')); + current.runtimeDiagnostic = shouldKeepUnsafeRuntimeDiagnostic + ? current.runtimeDiagnostic + : metadataRuntimeDiagnostic; + current.runtimeDiagnosticSeverity = shouldKeepUnsafeRuntimeDiagnostic + ? current.runtimeDiagnosticSeverity + : metadataRuntimeDiagnosticSeverity; current.sources = { ...(current.sources ?? {}), processAlive: observedRuntimeAlive || undefined, @@ -28775,6 +29777,7 @@ export class TeamProvisioningService { current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string'; if ( hadAutoClearableFailure && + !requiresConfirmedBootstrapToClearFailure && (bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance) ) { current.hardFailure = false; @@ -28786,8 +29789,12 @@ export class TeamProvisioningService { if ( current.bootstrapConfirmed && !isOpenCodeSecondaryLaneMember && - isAutoClearableLaunchFailureReason(current.hardFailureReason) + !hasUnsafeProvisionedButNotAliveFailure && + isBootstrapProofClearableLaunchFailureReason(current.hardFailureReason) ) { + if (isProvisionedButNotAliveFailureReason(current.hardFailureReason)) { + current.runtimeAlive = true; + } current.hardFailure = false; current.hardFailureReason = undefined; if (current.sources) { @@ -28806,9 +29813,10 @@ export class TeamProvisioningService { } const canApplyBootstrapSuccess = !heartbeatReason && + !hasUnsafeProvisionedButNotAliveFailure && (current.launchState !== 'failed_to_start' || hadAutoClearableFailure || - isAutoClearableLaunchFailureReason( + isBootstrapProofClearableLaunchFailureReason( current.hardFailureReason ?? current.runtimeDiagnostic )); if (!current.bootstrapConfirmed && canApplyBootstrapSuccess) { @@ -28828,7 +29836,9 @@ export class TeamProvisioningService { if (bootstrapObservedAt && !isOpenCodeSecondaryLaneMember) { current.bootstrapConfirmed = true; current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapObservedAt; - current.runtimeAlive = runtimeProofObservedAt ? true : current.runtimeAlive === true; + current.runtimeAlive = runtimeProofObservedAt + ? true + : current.runtimeAlive === true || requiresConfirmedBootstrapToClearFailure; current.lastRuntimeAliveAt = runtimeProofObservedAt ? (current.lastRuntimeAliveAt ?? bootstrapObservedAt) : current.lastRuntimeAliveAt; @@ -31445,6 +32455,7 @@ export class TeamProvisioningService { cwd: string; members: Record; expectedMembers: TeamRuntimeMemberSpec[]; + memberNames?: readonly string[]; teamColor?: string; teamDisplayName?: string; }): void { @@ -31454,6 +32465,7 @@ export class TeamProvisioningService { teamName: input.teamName, runId: input.runId, laneId: input.laneId, + memberNames: input.memberNames, providerId: 'opencode', }, entries @@ -34233,6 +35245,9 @@ export class TeamProvisioningService { args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId))); const providerArgs = env.providerArgs ?? []; providerArgsByProvider.set(providerId, providerArgs); + if (providerId === 'codex') { + Object.assign(envPatch, buildCodexCrossProviderSafeEnvPatch(env.env)); + } if (env.anthropicApiKeyHelper) { usesAnthropicApiKeyHelper = true; Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch); @@ -35601,7 +36616,10 @@ export class TeamProvisioningService { authenticated: boolean | null; providerStatus: Partial | null; } { - const providerStatus = parsed.providers?.[providerId] ?? null; + const providerStatus = + parsed.providers?.[providerId] ?? + (parsed.provider === providerId || !parsed.provider ? parsed.status : null) ?? + null; if (typeof providerStatus?.authenticated === 'boolean') { return { authenticated: providerStatus.authenticated, @@ -35643,13 +36661,14 @@ export class TeamProvisioningService { 'runtime', 'status', '--json', + '--summary', '--provider', providerId, ]), { cwd, env, - timeout: 8_000, + timeout: PROVIDER_RUNTIME_STATUS_TIMEOUT_MS, } ); const parsed = extractJsonObjectFromCli(runtimeStatus.stdout); diff --git a/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts b/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts index eb921f39..d69a42c4 100644 --- a/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts +++ b/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts @@ -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; } diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index 7c9c36c5..dc898ffb 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -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 { diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index fd67f0fa..3568e35e 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -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( diff --git a/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts b/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts index 51ab1e90..8f352e39 100644 --- a/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts +++ b/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts @@ -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, diff --git a/src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts b/src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts index 4eb1290f..ccac46c0 100644 --- a/src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts +++ b/src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts @@ -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 | 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, diff --git a/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts b/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts index 58165db5..be073191 100644 --- a/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts +++ b/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts @@ -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) || diff --git a/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts b/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts index a4ce7a4c..4bc8c574 100644 --- a/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts +++ b/src/main/services/team/provisioning/TeamProvisioningPromptBuilders.ts @@ -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` diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index fe77dbf5..e986e5c3 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -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; + listOpenCodeRuntimePermissions?( + input: OpenCodeListRuntimePermissionsCommandBody + ): Promise; } export interface OpenCodeTeamRuntimeMessageInput { @@ -599,6 +606,30 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { ); } + async listRuntimePermissions( + input: TeamRuntimePermissionListInput + ): Promise { + 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 { if (this.bridge.stopOpenCodeTeam) { const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName); diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index 22a2d909..d048f88e 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -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; + listRuntimePermissions?( + input: TeamRuntimePermissionListInput + ): Promise; } export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId { diff --git a/src/main/services/team/runtime/index.ts b/src/main/services/team/runtime/index.ts index bbf41d8f..4baec494 100644 --- a/src/main/services/team/runtime/index.ts +++ b/src/main/services/team/runtime/index.ts @@ -14,6 +14,8 @@ export type { TeamRuntimeMemberStopEvidence, TeamRuntimePendingApproval, TeamRuntimePendingPermission, + TeamRuntimePermissionListInput, + TeamRuntimePermissionListResult, TeamRuntimePrepareFailure, TeamRuntimePrepareResult, TeamRuntimePrepareSuccess, diff --git a/src/main/types/jsonl.ts b/src/main/types/jsonl.ts index 96b9b730..90c3667e 100644 --- a/src/main/types/jsonl.ts +++ b/src/main/types/jsonl.ts @@ -162,6 +162,10 @@ interface ConversationalEntry extends BaseEntry { */ export type ToolUseResultData = Record; +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; diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index dbcaa44a..889a1e61 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -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 { * - "..." -> 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: content - */ -const TEAMMATE_MESSAGE_REGEX = /^ block.type === 'text' && TEAMMATE_MESSAGE_REGEX.test(block.text.trim()) - ); - } - return false; +export function isHumanAuthoredParsedUserMessage(msg: ParsedMessage): boolean { + return msg.type === 'user' && isHumanAuthoredUserTurn(msg); } diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 480235d1..f56d2e9d 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -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 diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts index 4e9c95c4..278f8edb 100644 --- a/src/main/utils/metadataExtraction.ts +++ b/src/main/utils/metadataExtraction.ts @@ -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) { diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index b5ee70a6..38fc6aff 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -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['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')} diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index d494e068..9384abe4 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -54,6 +54,7 @@ import { import { formatProviderAuthMethodLabelForProvider, formatProviderAuthModeLabelForProvider, + formatProviderStatusText, getProviderConnectLabel, getProviderCurrentRuntimeSummary, isConnectionManagedRuntimeProvider, @@ -414,7 +415,7 @@ function getProviderUsageLabel( t ), }) - : provider.statusMessage || t('providerRuntime.usage.notConnected'); + : formatProviderStatusText(provider, t); } function getCompactOpenCodeProviderDetailMessage(detailMessage?: string | null): string | null { @@ -1150,10 +1151,8 @@ export const ProviderRuntimeSettingsDialog = ({ let connectionStatusLabel: string | null = null; if (selectedProvider) { - if (!hideConnectionMethodMeta && selectedProvider.authenticated) { + if (!hideConnectionMethodMeta) { connectionStatusLabel = getProviderUsageLabel(selectedProvider, t); - } else if (!hideConnectionMethodMeta) { - connectionStatusLabel = t('providerRuntime.usage.notConnected'); } } const showSelectedProviderSummary = Boolean(selectedProvider) && !connectionManagedRuntime; @@ -1635,13 +1634,7 @@ export const ProviderRuntimeSettingsDialog = ({ onProviderChanged={() => onRefreshProvider?.('opencode')} /> ) : ( -
+
diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts index e5fbb883..f8ade973 100644 --- a/src/renderer/components/runtime/providerConnectionUi.ts +++ b/src/renderer/components/runtime/providerConnectionUi.ts @@ -425,8 +425,8 @@ export function formatProviderStatusText( if (isProviderInventoryOnlyFallback(provider)) { return translateProviderConnection( t, - 'providerRuntime.connectionUi.status.checking', - 'Checking...' + 'providerRuntime.connectionUi.status.modelsAvailable', + 'Models available' ); } diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index ce5c61b4..3e16b9fb 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -862,9 +862,6 @@ export const CliStatusSection = (): React.JSX.Element | null => { setProviderTerminal(null); recheckStatus(); }} - onExit={() => { - recheckStatus(); - }} autoCloseOnSuccessMs={3000} successMessage={ providerTerminal.action === 'login' diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx index 300abf4e..9db050f9 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderRuntimeSettingsDialog.tsx @@ -180,10 +180,6 @@ export const ProvisioningProviderRuntimeSettingsDialog = ({ onProviderRuntimeChanged?.(providerTerminal.providerId); refreshRuntimeAfterTerminal(); }} - onExit={() => { - onProviderRuntimeChanged?.(providerTerminal.providerId); - refreshRuntimeAfterTerminal(); - }} autoCloseOnSuccessMs={3000} successMessage={ providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out' diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 9cfd5332..034b0373 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -28,6 +28,10 @@ import { import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary'; import { isLeadMember } from '@shared/utils/leadDetection'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; +import { + hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext, + isBootstrapConfirmedProvisionedButNotAliveFailure, +} from '@shared/utils/teamLaunchFailureReason'; import { Activity, AlertTriangle, @@ -661,12 +665,20 @@ export const MemberCard = memo(function MemberCard({ selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] ); const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); + const bootstrapConfirmedProvisionedButNotAlive = + isBootstrapConfirmedProvisionedButNotAliveFailure(spawnEntry); + const hasUnsafeBootstrapConfirmedProvisionedButNotAlive = + bootstrapConfirmedProvisionedButNotAlive && + hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext(spawnEntry, runtimeEntry); + const effectiveSpawnStatus = spawnStatus; + const effectiveSpawnLaunchState = spawnLaunchState; const showTaskActivity = shouldDisplayMemberCurrentTask({ member, isTeamAlive, - spawnStatus, - spawnLaunchState, + spawnStatus: effectiveSpawnStatus, + spawnLaunchState: effectiveSpawnLaunchState, spawnRuntimeAlive, + spawnEntry, runtimeEntry, }); const visibleCurrentTask = showTaskActivity ? currentTask : null; @@ -680,15 +692,19 @@ export const MemberCard = memo(function MemberCard({ : member; const launchPresentation = buildMemberLaunchPresentation({ member: presentationMember, - spawnStatus, - spawnLaunchState, + spawnStatus: effectiveSpawnStatus, + spawnLaunchState: effectiveSpawnLaunchState, spawnLivenessSource, spawnRuntimeAlive, spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed, 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, @@ -844,7 +860,7 @@ export const MemberCard = memo(function MemberCard({ const showStartingSkeleton = !isRemoved && presenceLabel === 'starting' && - spawnLaunchState !== 'failed_to_start' && + effectiveSpawnLaunchState !== 'failed_to_start' && !activityTask && !runtimeSummary; const usesLaunchSkeletonSurface = spawnCardClass.includes('member-waiting-shimmer'); @@ -869,8 +885,8 @@ export const MemberCard = memo(function MemberCard({ runId: runtimeRunId, memberName: member.name, member, - spawnStatus, - launchState: spawnLaunchState, + spawnStatus: effectiveSpawnStatus, + launchState: effectiveSpawnLaunchState, livenessSource: spawnLivenessSource, spawnEntry, runtimeEntry, @@ -886,9 +902,9 @@ export const MemberCard = memo(function MemberCard({ runtimeRunId, selectedTeamName, spawnEntry, - spawnLaunchState, + effectiveSpawnLaunchState, spawnLivenessSource, - spawnStatus, + effectiveSpawnStatus, ] ); const showCopyDiagnostics = @@ -900,7 +916,10 @@ export const MemberCard = memo(function MemberCard({ Boolean(runtimeAdvisoryLabel) && runtimeAdvisoryTone === 'error' && hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); - const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; + const isFailedLaunch = + (!bootstrapConfirmedProvisionedButNotAlive || + hasUnsafeBootstrapConfirmedProvisionedButNotAlive) && + (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'); const isSkippedLaunch = spawnStatus === 'skipped' || spawnLaunchState === 'skipped_for_launch' || diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 0a1ee0ec..fa80c2b8 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -24,6 +24,10 @@ import { } from '@renderer/utils/memberRuntimeSummary'; import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { + hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext, + isBootstrapConfirmedProvisionedButNotAliveFailure, +} from '@shared/utils/teamLaunchFailureReason'; import { isTeamTaskFinishedForDependency } from '@shared/utils/teamTaskState'; import { BarChart3, @@ -83,7 +87,14 @@ function isOpenCodeNoRuntimeEvidenceFailure( spawnEntry: MemberSpawnStatusEntry | undefined, runtimeEntry: TeamAgentRuntimeEntry | undefined ): boolean { - const failed = spawnEntry?.launchState === 'failed_to_start' || spawnEntry?.status === 'error'; + const bootstrapConfirmedProvisionedButNotAlive = + isBootstrapConfirmedProvisionedButNotAliveFailure(spawnEntry); + const unsafeProvisionedButNotAlive = + bootstrapConfirmedProvisionedButNotAlive && + hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext(spawnEntry, runtimeEntry); + const failed = + (!bootstrapConfirmedProvisionedButNotAlive || unsafeProvisionedButNotAlive) && + (spawnEntry?.launchState === 'failed_to_start' || spawnEntry?.status === 'error'); return member.providerId === 'opencode' && failed && !hasOpenCodeRuntimeEvidence(runtimeEntry); } @@ -180,6 +191,7 @@ export const MemberDetailDialog = ({ spawnStatus: spawnEntry?.status, spawnLaunchState: spawnEntry?.launchState, spawnRuntimeAlive: spawnEntry?.runtimeAlive, + spawnEntry, runtimeEntry, }); const displayableCurrentTask = @@ -303,7 +315,11 @@ export const MemberDetailDialog = ({ 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={runtimeEntry} diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index b1c9acb7..e61d3a17 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -25,6 +25,7 @@ import type { MemberSpawnLivenessSource, MemberSpawnStatus, ResolvedTeamMember, + TeamAgentRuntimeDiagnosticSeverity, TeamAgentRuntimeEntry, } from '@shared/types'; @@ -43,7 +44,11 @@ interface MemberDetailHeaderProps { spawnBootstrapStalled?: boolean; spawnAgentToolAccepted?: boolean; spawnHardFailure?: boolean; + spawnHardFailureReason?: string; + spawnError?: string; + spawnRuntimeDiagnostic?: string; spawnLivenessKind?: TeamAgentRuntimeEntry['livenessKind']; + spawnRuntimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; spawnFirstSpawnAcceptedAt?: string; spawnUpdatedAt?: string; isLaunchSettling?: boolean; @@ -66,7 +71,11 @@ export const MemberDetailHeader = ({ spawnBootstrapStalled, spawnAgentToolAccepted, spawnHardFailure, + spawnHardFailureReason, + spawnError, + spawnRuntimeDiagnostic, spawnLivenessKind, + spawnRuntimeDiagnosticSeverity, spawnFirstSpawnAcceptedAt, spawnUpdatedAt, isLaunchSettling, @@ -99,7 +108,11 @@ export const MemberDetailHeader = ({ spawnBootstrapStalled, spawnAgentToolAccepted, spawnHardFailure, + spawnHardFailureReason, + spawnError, + spawnRuntimeDiagnostic, spawnLivenessKind, + spawnRuntimeDiagnosticSeverity, spawnFirstSpawnAcceptedAt, spawnUpdatedAt, runtimeEntry, diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx index 0bc94f54..8957e3d3 100644 --- a/src/renderer/components/team/members/MemberHoverCard.tsx +++ b/src/renderer/components/team/members/MemberHoverCard.tsx @@ -147,6 +147,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({ spawnStatus: spawnEntry?.status, spawnLaunchState: spawnEntry?.launchState, spawnRuntimeAlive: spawnEntry?.runtimeAlive, + spawnEntry, runtimeEntry, }) ? currentTaskCandidate @@ -168,7 +169,11 @@ export const MemberHoverCard = memo(function MemberHoverCard({ 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, @@ -226,6 +231,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({ spawnStatus: spawnEntry?.status, spawnLaunchState: spawnEntry?.launchState, spawnRuntimeAlive: spawnEntry?.runtimeAlive, + spawnEntry, runtimeEntry, }) ? reviewTaskCandidate diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 5e3732a9..5f6ea327 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -11,6 +11,10 @@ import { buildMemberColorMap, shouldDisplayMemberCurrentTask } from '@renderer/u import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { + hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext, + isBootstrapConfirmedProvisionedButNotAliveFailure, +} from '@shared/utils/teamLaunchFailureReason'; import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState'; import { MemberCard, type RuntimeTelemetryScale } from './MemberCard'; @@ -785,6 +789,7 @@ export const MemberList = memo(function MemberList({ spawnStatus: spawnEntry?.status, spawnLaunchState: spawnEntry?.launchState, spawnRuntimeAlive: spawnEntry?.runtimeAlive, + spawnEntry, runtimeEntry, }); }, @@ -837,6 +842,7 @@ export const MemberList = memo(function MemberList({ spawnStatus: spawnEntry?.status, spawnLaunchState: spawnEntry?.launchState, spawnRuntimeAlive: spawnEntry?.runtimeAlive, + spawnEntry, runtimeEntry, }); syncMemberActivityTimer({ @@ -924,6 +930,32 @@ export const MemberList = memo(function MemberList({ {activeMembers.map((member) => { const spawnEntry = memberSpawnStatuses?.get(member.name); const runtimeEntry = memberRuntimeEntries?.get(member.name); + const bootstrapConfirmedProvisionedButNotAlive = + isBootstrapConfirmedProvisionedButNotAliveFailure(spawnEntry); + const hasUnsafeProvisionedButNotAliveEvidence = + bootstrapConfirmedProvisionedButNotAlive && + hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext( + spawnEntry, + runtimeEntry + ); + const canPromoteBootstrapConfirmedVisualState = + bootstrapConfirmedProvisionedButNotAlive && + spawnEntry?.runtimeDiagnosticSeverity !== 'error' && + runtimeEntry?.runtimeDiagnosticSeverity !== 'error' && + !hasUnsafeProvisionedButNotAliveEvidence; + const effectiveSpawnStatus = canPromoteBootstrapConfirmedVisualState + ? 'online' + : spawnEntry?.status; + const effectiveSpawnLaunchState = canPromoteBootstrapConfirmedVisualState + ? 'confirmed_alive' + : spawnEntry?.launchState; + const useBootstrapConfirmedRuntimeAlive = + canPromoteBootstrapConfirmedVisualState && + runtimeEntry?.runtimeDiagnosticSeverity !== 'error' && + spawnEntry?.runtimeDiagnosticSeverity !== 'error'; + const effectiveSpawnRuntimeAlive = useBootstrapConfirmedRuntimeAlive + ? true + : spawnEntry?.runtimeAlive; const currentTaskCandidate = member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; const currentTask = @@ -931,9 +963,10 @@ export const MemberList = memo(function MemberList({ shouldDisplayMemberCurrentTask({ member, isTeamAlive, - spawnStatus: spawnEntry?.status, - spawnLaunchState: spawnEntry?.launchState, - spawnRuntimeAlive: spawnEntry?.runtimeAlive, + spawnStatus: effectiveSpawnStatus, + spawnLaunchState: effectiveSpawnLaunchState, + spawnRuntimeAlive: effectiveSpawnRuntimeAlive, + spawnEntry, runtimeEntry, }) ? currentTaskCandidate @@ -945,9 +978,10 @@ export const MemberList = memo(function MemberList({ shouldDisplayMemberCurrentTask({ member, isTeamAlive, - spawnStatus: spawnEntry?.status, - spawnLaunchState: spawnEntry?.launchState, - spawnRuntimeAlive: spawnEntry?.runtimeAlive, + spawnStatus: effectiveSpawnStatus, + spawnLaunchState: effectiveSpawnLaunchState, + spawnRuntimeAlive: effectiveSpawnRuntimeAlive, + spawnEntry, runtimeEntry, }) ? reviewCandidate @@ -995,12 +1029,16 @@ export const MemberList = memo(function MemberList({ runtimeSummary={buildRuntimeSummary(member, spawnEntry, runtimeEntry)} runtimeEntry={runtimeEntry} runtimeRunId={runtimeRunId} - spawnStatus={spawnEntry?.status} + spawnStatus={effectiveSpawnStatus} spawnEntry={spawnEntry} - spawnError={spawnEntry?.error ?? spawnEntry?.hardFailureReason} + spawnError={ + canPromoteBootstrapConfirmedVisualState + ? undefined + : (spawnEntry?.error ?? spawnEntry?.hardFailureReason) + } spawnLivenessSource={spawnEntry?.livenessSource} - spawnLaunchState={spawnEntry?.launchState} - spawnRuntimeAlive={spawnEntry?.runtimeAlive} + spawnLaunchState={effectiveSpawnLaunchState} + spawnRuntimeAlive={effectiveSpawnRuntimeAlive} isTeamAlive={isTeamAlive} isTeamProvisioning={isTeamProvisioning} leadActivity={leadActivity} diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index a8fb62bb..3f34dd2d 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -1,4 +1,10 @@ import { isLeadMember } from '@shared/utils/leadDetection'; +import { + hasUnsafeProvisionedButNotAliveRuntimeEvidence, + hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext, + isBootstrapConfirmedProvisionedButNotAliveFailure, + mentionsProcessTableUnavailable, +} from '@shared/utils/teamLaunchFailureReason'; import type { MemberSpawnStatusEntry, @@ -80,6 +86,9 @@ function parseStatusUpdatedAtMs(value: string | undefined): number | null { } function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean { + if (isBootstrapConfirmedProvisionedButNotAliveFailure(entry)) { + return hasUnsafeProvisionedButNotAliveRuntimeEvidence(entry); + } return entry?.launchState === 'failed_to_start' || entry?.status === 'error'; } @@ -92,13 +101,62 @@ function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolea } function isConfirmedSpawnEntry(entry: MemberSpawnStatusEntry): boolean { + if (isBootstrapConfirmedProvisionedButNotAliveFailure(entry)) { + return !isFailedSpawnEntry(entry); + } return entry.launchState === 'confirmed_alive' || entry.bootstrapConfirmed === true; } +function spawnEntryContradictsConfirmedJoin(entry: MemberSpawnStatusEntry): boolean { + if (!isConfirmedSpawnEntry(entry) || entry.runtimeAlive !== false) { + return false; + } + if (entry.runtimeDiagnosticSeverity === 'error') { + return true; + } + if ( + entry.livenessKind === 'not_found' || + entry.livenessKind === 'shell_only' || + entry.livenessKind === 'permission_blocked' || + entry.livenessKind === 'runtime_process_candidate' + ) { + return true; + } + const hasProcessTableUnavailableMarker = + mentionsProcessTableUnavailable(entry.runtimeDiagnostic) || + mentionsProcessTableUnavailable(entry.hardFailureReason) || + mentionsProcessTableUnavailable(entry.error); + if (!entry.livenessKind) { + return !hasProcessTableUnavailableMarker; + } + if (entry.livenessKind !== 'registered_only' && entry.livenessKind !== 'stale_metadata') { + return false; + } + return !hasProcessTableUnavailableMarker; +} + function runtimeEntryContradictsConfirmedJoin( + entry: MemberSpawnStatusEntry, runtimeEntry: TeamAgentRuntimeEntry | undefined ): boolean { - return runtimeEntry?.alive === false; + if (runtimeEntry?.alive !== false || runtimeEntry.livenessKind === 'confirmed_bootstrap') { + return false; + } + if ( + isBootstrapConfirmedProvisionedButNotAliveFailure(entry) && + !hasUnsafeProvisionedButNotAliveRuntimeEvidence(entry) && + !hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext(entry, runtimeEntry) && + (runtimeEntry.livenessKind == null || + runtimeEntry.livenessKind === 'registered_only' || + runtimeEntry.livenessKind === 'stale_metadata') && + (mentionsProcessTableUnavailable(runtimeEntry.runtimeDiagnostic) || + mentionsProcessTableUnavailable(entry.runtimeDiagnostic) || + mentionsProcessTableUnavailable(entry.hardFailureReason) || + mentionsProcessTableUnavailable(entry.error)) + ) { + return false; + } + return true; } function shouldPreferSnapshotEntryOverLive( @@ -159,7 +217,7 @@ function summarizeLiveLaunchJoinMilestones(params: { continue; } observedTeammateCount += 1; - if (entry.launchState === 'failed_to_start') { + if (isFailedSpawnEntry(entry)) { failedSpawnCount += 1; continue; } @@ -167,14 +225,21 @@ function summarizeLiveLaunchJoinMilestones(params: { skippedSpawnCount += 1; continue; } + if (spawnEntryContradictsConfirmedJoin(entry)) { + pendingSpawnCount += 1; + continue; + } if ( isConfirmedSpawnEntry(entry) && - runtimeEntryContradictsConfirmedJoin(getRuntimeEntry(params.memberRuntimeEntries, memberName)) + runtimeEntryContradictsConfirmedJoin( + entry, + getRuntimeEntry(params.memberRuntimeEntries, memberName) + ) ) { pendingSpawnCount += 1; continue; } - if (entry.launchState === 'confirmed_alive') { + if (isConfirmedSpawnEntry(entry)) { heartbeatConfirmedCount += 1; continue; } diff --git a/src/renderer/components/team/teamRuntimeDisplayRows.ts b/src/renderer/components/team/teamRuntimeDisplayRows.ts index 0737cbf0..65367a15 100644 --- a/src/renderer/components/team/teamRuntimeDisplayRows.ts +++ b/src/renderer/components/team/teamRuntimeDisplayRows.ts @@ -1,3 +1,9 @@ +import { + hasUnsafeProvisionedButNotAliveRuntimeEvidence, + hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext, + isBootstrapConfirmedProvisionedButNotAliveFailure, +} from '@shared/utils/teamLaunchFailureReason'; + import type { MemberSpawnStatusEntry, TeamAgentRuntimeDiagnosticSeverity, @@ -139,15 +145,29 @@ function buildRuntimeBackedDisplayRow( spawn?: MemberSpawnStatusEntry ): TeamRuntimeDisplayRow { const hasErrorDiagnostic = runtime.runtimeDiagnosticSeverity === 'error'; + const bootstrapConfirmedProvisionedButNotAlive = + isBootstrapConfirmedProvisionedButNotAliveFailure(spawn); const spawnDegradation = getSpawnDegradation(spawn); + const unsafeRuntimeEvidence = hasUnsafeProvisionedButNotAliveRuntimeEvidenceWithSpawnContext( + spawn, + runtime + ); + const useBootstrapConfirmedState = + bootstrapConfirmedProvisionedButNotAlive && + !hasErrorDiagnostic && + !unsafeRuntimeEvidence && + spawnDegradation == null; const spawnStoppedEvidence = spawnDegradation ? null : getSpawnStoppedEvidence(runtime, spawn); - const state = spawnStoppedEvidence - ? 'stopped' - : getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null); + const state = useBootstrapConfirmedState + ? 'running' + : spawnStoppedEvidence + ? 'stopped' + : getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null); const degradedReason = spawnDegradation ? withLiveProcessContext(spawnDegradation.reason, runtime) : undefined; const stateReason = + (useBootstrapConfirmedState ? 'Bootstrap confirmed' : undefined) ?? degradedReason ?? spawnStoppedEvidence?.reason ?? runtime.runtimeDiagnostic ?? @@ -181,6 +201,17 @@ function buildRuntimeBackedDisplayRow( function getSpawnDegradation(spawn?: MemberSpawnStatusEntry): SpawnDegradation | null { if (!spawn) return null; + if (isBootstrapConfirmedProvisionedButNotAliveFailure(spawn)) { + if (!hasUnsafeProvisionedButNotAliveRuntimeEvidence(spawn)) { + return null; + } + const reason = spawn.runtimeDiagnostic ?? 'Runtime launch status needs attention'; + return { + reason, + diagnostic: spawn.runtimeDiagnostic ?? reason, + diagnosticSeverity: spawn.runtimeDiagnosticSeverity === 'error' ? 'error' : 'warning', + }; + } if (spawn.status === 'error' || spawn.hardFailure === true) { const reason = @@ -226,7 +257,10 @@ function getSpawnStoppedEvidence( runtime: TeamAgentRuntimeEntry, spawn?: MemberSpawnStatusEntry ): SpawnStoppedEvidence | null { - if (!spawn || spawn.runtimeAlive !== false || runtime.livenessKind !== 'confirmed_bootstrap') { + if (isBootstrapConfirmedProvisionedButNotAliveFailure(spawn)) { + return null; + } + if (spawn?.runtimeAlive !== false || runtime.livenessKind !== 'confirmed_bootstrap') { return null; } if (spawn.status !== 'online' && spawn.launchState !== 'confirmed_alive') { @@ -267,6 +301,23 @@ function buildSpawnBackedDisplayRow( memberName: string, spawn: MemberSpawnStatusEntry ): TeamRuntimeDisplayRow { + if ( + isBootstrapConfirmedProvisionedButNotAliveFailure(spawn) && + !hasUnsafeProvisionedButNotAliveRuntimeEvidence(spawn) + ) { + return { + memberName, + state: 'running', + stateReason: 'Bootstrap confirmed', + source: 'spawn-status', + updatedAt: spawn.livenessLastCheckedAt ?? spawn.lastHeartbeatAt ?? spawn.updatedAt, + runtimeModel: spawn.runtimeModel, + diagnostic: spawn.runtimeDiagnostic, + diagnosticSeverity: spawn.runtimeDiagnosticSeverity, + actionsAllowed: false, + }; + } + const spawnDegradation = getSpawnDegradation(spawn); if (spawnDegradation) { return { @@ -359,6 +410,7 @@ function buildSpawnBackedDisplayRow( } function getSpawnOnlyStoppedEvidence(spawn: MemberSpawnStatusEntry): SpawnStoppedEvidence | null { + if (isBootstrapConfirmedProvisionedButNotAliveFailure(spawn)) return null; if (spawn.runtimeAlive !== false) return null; if (spawn.status !== 'online' && spawn.launchState !== 'confirmed_alive') return null; diff --git a/src/renderer/components/terminal/EmbeddedTerminal.test.tsx b/src/renderer/components/terminal/EmbeddedTerminal.test.tsx new file mode 100644 index 00000000..3abebcd7 --- /dev/null +++ b/src/renderer/components/terminal/EmbeddedTerminal.test.tsx @@ -0,0 +1,172 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { api } from '@renderer/api'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { EmbeddedTerminal } from './EmbeddedTerminal'; + +vi.mock('@renderer/api', () => ({ + api: { + openExternal: vi.fn(), + terminal: { + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(), + onExit: vi.fn(), + }, + }, +})); + +vi.mock('@xterm/xterm', () => ({ + Terminal: vi.fn().mockImplementation(() => ({ + cols: 80, + rows: 24, + loadAddon: vi.fn(), + open: vi.fn(), + attachCustomKeyEventHandler: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + getSelection: vi.fn(() => ''), + write: vi.fn(), + dispose: vi.fn(), + })), +})); + +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: vi.fn().mockImplementation(() => ({ + fit: vi.fn(), + })), +})); + +vi.mock('@xterm/addon-web-links', () => ({ + WebLinksAddon: vi.fn().mockImplementation(() => ({})), +})); + +let frameCallbacks: Map; +let nextFrameId: number; + +function flushFrames(): void { + const callbacks = Array.from(frameCallbacks.values()); + frameCallbacks.clear(); + callbacks.forEach((callback) => callback(performance.now())); +} + +async function flushReact(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('EmbeddedTerminal', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + frameCallbacks = new Map(); + nextFrameId = 1; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + const id = nextFrameId; + nextFrameId += 1; + frameCallbacks.set(id, callback); + return id; + }) + ); + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((id: number) => { + frameCallbacks.delete(id); + }) + ); + vi.stubGlobal( + 'ResizeObserver', + vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + disconnect: vi.fn(), + })) + ); + + vi.mocked(api.terminal.spawn).mockResolvedValue('pty-1'); + vi.mocked(api.terminal.onData).mockReturnValue(() => undefined); + vi.mocked(api.terminal.onExit).mockReturnValue(() => undefined); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('does not spawn twice during React StrictMode effect replay', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + + + + ); + await flushReact(); + }); + + expect(api.terminal.spawn).not.toHaveBeenCalled(); + + await act(async () => { + flushFrames(); + await flushReact(); + }); + + expect(api.terminal.spawn).toHaveBeenCalledTimes(1); + expect(api.terminal.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/bin/runtime', + args: ['auth', 'login'], + }) + ); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); + + it('kills a PTY that resolves after the terminal unmounts', async () => { + let resolveSpawn: (id: string) => void = () => {}; + vi.mocked(api.terminal.spawn).mockReturnValue( + new Promise((resolve) => { + resolveSpawn = resolve; + }) + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(); + await flushReact(); + }); + + await act(async () => { + flushFrames(); + await flushReact(); + }); + + expect(api.terminal.spawn).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + + await act(async () => { + resolveSpawn('late-pty'); + await flushReact(); + }); + + expect(api.terminal.kill).toHaveBeenCalledWith('late-pty'); + }); +}); diff --git a/src/renderer/components/terminal/EmbeddedTerminal.tsx b/src/renderer/components/terminal/EmbeddedTerminal.tsx index 78acc77d..3e39c938 100644 --- a/src/renderer/components/terminal/EmbeddedTerminal.tsx +++ b/src/renderer/components/terminal/EmbeddedTerminal.tsx @@ -64,8 +64,43 @@ export const EmbeddedTerminal = ({ term.open(container); - // Fit after opening so dimensions are correct - const rafId = requestAnimationFrame(() => fitAddon.fit()); + const spawnTerminal = (): void => { + if (disposed) return; + + const spawnOptions: PtySpawnOptions = { + ...(command ? { command } : {}), + ...(args ? { args } : {}), + ...(cwd ? { cwd } : {}), + ...(env ? { env } : {}), + cols: term.cols, + rows: term.rows, + }; + + api.terminal + .spawn(spawnOptions) + .then((id) => { + if (disposed) { + api.terminal.kill(id); + return; + } + ptyId = id; + // Send actual terminal size after spawn (fitAddon.fit() may have changed cols/rows). + api.terminal.resize(id, term.cols, term.rows); + }) + .catch((err: unknown) => { + if (disposed) return; + term.write( + `\r\n\x1b[31mFailed to start terminal: ${err instanceof Error ? err.message : String(err)}\x1b[0m\r\n` + ); + }); + }; + + // Defer spawning until after the first frame. React StrictMode replays effects + // in development; canceling this RAF prevents duplicate one-shot commands. + const rafId = requestAnimationFrame(() => { + fitAddon.fit(); + spawnTerminal(); + }); // Ctrl+C with selection → copy to clipboard (instead of sending SIGINT) term.attachCustomKeyEventHandler((event) => { @@ -97,32 +132,6 @@ export const EmbeddedTerminal = ({ } }); - // Spawn PTY - const spawnOptions: PtySpawnOptions = { - ...(command ? { command } : {}), - ...(args ? { args } : {}), - ...(cwd ? { cwd } : {}), - ...(env ? { env } : {}), - cols: term.cols, - rows: term.rows, - }; - - api.terminal - .spawn(spawnOptions) - .then((id) => { - if (disposed) return; - ptyId = id; - // Send actual terminal size after spawn (fitAddon.fit() may have - // changed cols/rows via RAF after spawnOptions was constructed) - api.terminal.resize(id, term.cols, term.rows); - }) - .catch((err: unknown) => { - if (disposed) return; - term.write( - `\r\n\x1b[31mFailed to start terminal: ${err instanceof Error ? err.message : String(err)}\x1b[0m\r\n` - ); - }); - // ResizeObserver → fitAddon.fit() → pty.resize() const observer = new ResizeObserver(() => { fitAddon.fit(); diff --git a/src/renderer/components/ui/ChipInteractionLayer.tsx b/src/renderer/components/ui/ChipInteractionLayer.tsx index 521c1365..173ed897 100644 --- a/src/renderer/components/ui/ChipInteractionLayer.tsx +++ b/src/renderer/components/ui/ChipInteractionLayer.tsx @@ -171,6 +171,34 @@ interface ChipInteractionLayerProps { onRemove: (chipId: string) => void; } +function areChipsEquivalent(a: InlineChip, b: InlineChip): boolean { + return ( + a.id === b.id && + a.filePath === b.filePath && + a.fileName === b.fileName && + a.fromLine === b.fromLine && + a.toLine === b.toLine && + a.codeText === b.codeText && + a.displayPath === b.displayPath && + a.isFolder === b.isFolder + ); +} + +function areChipPositionsEquivalent(current: ChipPosition[], next: ChipPosition[]): boolean { + if (current.length !== next.length) return false; + + return current.every((position, index) => { + const nextPosition = next[index]; + return ( + position.top === nextPosition.top && + position.left === nextPosition.left && + position.width === nextPosition.width && + position.height === nextPosition.height && + areChipsEquivalent(position.chip, nextPosition.chip) + ); + }); +} + export const ChipInteractionLayer = ({ chips, value, @@ -179,18 +207,25 @@ export const ChipInteractionLayer = ({ onRemove, }: ChipInteractionLayerProps): React.JSX.Element | null => { const [positions, setPositions] = React.useState([]); + const positionsRef = React.useRef([]); const revealFileInEditor = useStore((s) => s.revealFileInEditor); const revealFolderInEditor = useStore((s) => s.revealFolderInEditor); + const commitPositions = React.useCallback((nextPositions: ChipPosition[]) => { + if (areChipPositionsEquivalent(positionsRef.current, nextPositions)) return; + positionsRef.current = nextPositions; + setPositions(nextPositions); + }, []); + React.useLayoutEffect(() => { if (chips.length === 0) { - setPositions([]); + commitPositions([]); return; } const textarea = textareaRef.current; if (!textarea) return; - setPositions(calculateChipPositions(textarea, value, chips)); - }, [chips, value, textareaRef]); + commitPositions(calculateChipPositions(textarea, value, chips)); + }, [chips, commitPositions, value, textareaRef]); if (positions.length === 0) return null; @@ -200,6 +235,14 @@ export const ChipInteractionLayer = ({ {positions.map((pos) => { const isFileChip = pos.chip.fromLine == null; const isFolderChip = pos.chip.isFolder === true; + const openChipTarget = (): void => { + if (isFolderChip) { + revealFolderInEditor(pos.chip.filePath); + } else { + revealFileInEditor(pos.chip.filePath); + } + }; + return ( @@ -211,20 +254,19 @@ export const ChipInteractionLayer = ({ width: pos.width, height: pos.height, }} - onClick={ - isFileChip - ? (e) => { - e.preventDefault(); - e.stopPropagation(); - if (isFolderChip) { - revealFolderInEditor(pos.chip.filePath); - } else { - revealFileInEditor(pos.chip.filePath); - } - } - : undefined - } > + {isFileChip ? ( +