merge: dev into main

This commit is contained in:
777genius 2026-05-23 01:06:28 +03:00
commit 22d5be54d3
159 changed files with 17809 additions and 3770 deletions

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="103" height="20" role="img" aria-label="version: v2.1.0"><title>version: v2.1.0</title><g shape-rendering="crispEdges"><rect width="51" height="20" fill="#555"/><rect x="51" width="52" height="20" fill="#007ec6"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"><text x="25" y="14">version</text><text x="77" y="14">v2.1.0</text></g></svg> <svg xmlns="http://www.w3.org/2000/svg" width="103" height="20" role="img" aria-label="version: v2.1.1"><title>version: v2.1.1</title><g shape-rendering="crispEdges"><rect width="51" height="20" fill="#555"/><rect x="51" width="52" height="20" fill="#007ec6"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"><text x="25" y="14">version</text><text x="77" y="14">v2.1.1</text></g></svg>

Before

Width:  |  Height:  |  Size: 445 B

After

Width:  |  Height:  |  Size: 445 B

View file

@ -687,17 +687,53 @@ jobs:
["agent-teams-ai.pacman"]="agent-teams-ai-${VERSION}.pacman" ["agent-teams-ai.pacman"]="agent-teams-ai-${VERSION}.pacman"
) )
for ALIAS_NAME in "${!STABLE_ALIASES[@]}"; do declare -A LEGACY_STABLE_ALIASES=(
VERSIONED_NAME="${STABLE_ALIASES[$ALIAS_NAME]}" ["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg"
echo "Uploading stable alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}" ["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg"
["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe"
["Claude-Agent-Teams-UI.AppImage"]="Agent.Teams.AI-${VERSION}.AppImage"
["Claude-Agent-Teams-UI-amd64.deb"]="agent-teams-ai_${VERSION}_amd64.deb"
["Claude-Agent-Teams-UI-x86_64.rpm"]="agent-teams-ai-${VERSION}.x86_64.rpm"
["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman"
)
declare -A LEGACY_UPDATER_ALIASES=(
["Claude.Agent.Teams.UI-${VERSION}-arm64-mac.zip"]="Agent.Teams.AI-${VERSION}-arm64-mac.zip"
["Claude.Agent.Teams.UI-${VERSION}-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg"
["Claude.Agent.Teams.UI-${VERSION}-mac.zip"]="Agent.Teams.AI-${VERSION}-x64-mac.zip"
["Claude.Agent.Teams.UI-${VERSION}.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg"
["Claude.Agent.Teams.UI.Setup.${VERSION}.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe"
["Claude.Agent.Teams.UI-${VERSION}.AppImage"]="Agent.Teams.AI-${VERSION}.AppImage"
)
upload_aliases() {
local label="$1"
local -n aliases="$2"
for ALIAS_NAME in "${!aliases[@]}"; do
VERSIONED_NAME="${aliases[$ALIAS_NAME]}"
echo "Uploading ${label} alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}"
download_once "${VERSIONED_NAME}"
cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${ALIAS_NAME}"
gh release upload "${TAG}" "${TMP_DIR}/${ALIAS_NAME}" --repo "$REPO" --clobber
done
}
download_once() {
local name="$1"
if [[ -f "${TMP_DIR}/${name}" ]]; then
return
fi
gh release download "${TAG}" \ gh release download "${TAG}" \
--repo "$REPO" \ --repo "$REPO" \
--pattern "${VERSIONED_NAME}" \ --pattern "${name}" \
--dir "$TMP_DIR" \ --dir "$TMP_DIR" \
--clobber --clobber
cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${ALIAS_NAME}" }
gh release upload "${TAG}" "${TMP_DIR}/${ALIAS_NAME}" --repo "$REPO" --clobber
done upload_aliases "stable" STABLE_ALIASES
upload_aliases "legacy stable" LEGACY_STABLE_ALIASES
upload_aliases "legacy updater" LEGACY_UPDATER_ALIASES
- name: Publish canonical updater metadata - name: Publish canonical updater metadata
env: env:
@ -761,26 +797,37 @@ jobs:
EOF EOF
# Canonical macOS feed. # Canonical macOS feed.
# electron-updater on GitHub still consumes a single latest-mac.yml, so we # Include both architectures so legacy Intel builds can see the
# publish the Apple Silicon feed here and suppress Intel auto-update in-app # update without downloading the Apple Silicon zip.
# until we switch to universal packaging or an arch-aware provider.
download_asset "Agent.Teams.AI-${VERSION}-arm64-mac.zip" download_asset "Agent.Teams.AI-${VERSION}-arm64-mac.zip"
download_asset "Agent.Teams.AI-${VERSION}-arm64.dmg" download_asset "Agent.Teams.AI-${VERSION}-arm64.dmg"
MAC_ZIP_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64-mac.zip")" download_asset "Agent.Teams.AI-${VERSION}-x64-mac.zip"
MAC_ZIP_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64-mac.zip")" download_asset "Agent.Teams.AI-${VERSION}-x64.dmg"
MAC_DMG_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64.dmg")" MAC_ARM64_ZIP_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64-mac.zip")"
MAC_DMG_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64.dmg")" MAC_ARM64_ZIP_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64-mac.zip")"
MAC_ARM64_DMG_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64.dmg")"
MAC_ARM64_DMG_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64.dmg")"
MAC_X64_ZIP_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-x64-mac.zip")"
MAC_X64_ZIP_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-x64-mac.zip")"
MAC_X64_DMG_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-x64.dmg")"
MAC_X64_DMG_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-x64.dmg")"
cat > latest-mac.yml <<EOF cat > latest-mac.yml <<EOF
version: ${VERSION} version: ${VERSION}
files: files:
- url: Agent.Teams.AI-${VERSION}-arm64-mac.zip - url: Agent.Teams.AI-${VERSION}-arm64-mac.zip
sha512: ${MAC_ZIP_SHA} sha512: ${MAC_ARM64_ZIP_SHA}
size: ${MAC_ZIP_SIZE} size: ${MAC_ARM64_ZIP_SIZE}
- url: Agent.Teams.AI-${VERSION}-arm64.dmg - url: Agent.Teams.AI-${VERSION}-arm64.dmg
sha512: ${MAC_DMG_SHA} sha512: ${MAC_ARM64_DMG_SHA}
size: ${MAC_DMG_SIZE} size: ${MAC_ARM64_DMG_SIZE}
- url: Agent.Teams.AI-${VERSION}-x64-mac.zip
sha512: ${MAC_X64_ZIP_SHA}
size: ${MAC_X64_ZIP_SIZE}
- url: Agent.Teams.AI-${VERSION}-x64.dmg
sha512: ${MAC_X64_DMG_SHA}
size: ${MAC_X64_DMG_SIZE}
path: Agent.Teams.AI-${VERSION}-arm64-mac.zip path: Agent.Teams.AI-${VERSION}-arm64-mac.zip
sha512: ${MAC_ZIP_SHA} sha512: ${MAC_ARM64_ZIP_SHA}
releaseDate: '${RELEASE_DATE}' releaseDate: '${RELEASE_DATE}'
EOF EOF

View file

@ -75,7 +75,9 @@ No prerequisites - the app can detect supported runtimes/providers and guide set
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" /> <img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
</a> </a>
<br /> <br />
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub> <sub>May trigger SmartScreen - click "More info" -> "Run anyway"</sub>
<br />
<sub><strong>Windows required:</strong> launch Agent Teams AI as Administrator, especially when using OpenCode runtimes.</sub>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Agent.Teams.AI.AppImage"> <a href="https://github.com/777genius/agent-teams-ai/releases/latest/download/Agent.Teams.AI.AppImage">

BIN
hero-robots-restored.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

View file

@ -6,10 +6,24 @@ import {
mdiShieldCheckOutline, mdiShieldCheckOutline,
mdiMonitorDashboard, mdiMonitorDashboard,
} from "@mdi/js"; } from "@mdi/js";
import { getLocalizedHeroFeatureRail } from "~/data/heroScene"; import {
heroCollaborationFeature,
getLocalizedHeroFeatureRail,
getLocalizedHeroReviewerFeatureCard,
type HeroMessage,
type HeroMessagePhase,
} from "~/data/heroScene";
const props = defineProps<{
activeMessage?: HeroMessage | null;
phase?: HeroMessagePhase;
reducedMotion?: boolean;
}>();
const { locale } = useI18n(); const { locale } = useI18n();
const localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value)); const localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value));
const localizedHeroReviewerFeatureCard = computed(() => getLocalizedHeroReviewerFeatureCard(locale.value));
const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:");
const icons = [ const icons = [
mdiRobotOutline, mdiRobotOutline,
@ -18,10 +32,74 @@ const icons = [
mdiShieldCheckOutline, mdiShieldCheckOutline,
mdiMonitorDashboard, mdiMonitorDashboard,
] as const; ] as const;
const reviewerIsSender = computed(() =>
props.activeMessage?.from === "reviewer" && props.phase !== "cooldown",
);
const reviewerIsReceiver = computed(() =>
props.activeMessage?.to === "reviewer" && props.phase === "receiver",
);
const reviewerIsActive = computed(() => reviewerIsSender.value || reviewerIsReceiver.value);
const reviewerBubbleText = computed(() => {
if (!props.activeMessage || props.reducedMotion) return null;
if (props.activeMessage.from === "reviewer" && (props.phase === "sender" || props.phase === "packet")) {
return props.activeMessage.text;
}
if (props.activeMessage.to === "reviewer" && props.phase === "receiver") {
return props.activeMessage.response;
}
return null;
});
</script> </script>
<template> <template>
<div class="cyber-feature-rail-shell"> <div class="cyber-feature-rail-shell">
<img
class="cyber-feature-rail__collaboration"
:src="heroCollaborationFeature.asset"
alt=""
loading="eager"
fetchpriority="high"
decoding="async"
aria-hidden="true"
>
<div
class="cyber-feature-rail__reviewer"
:class="{
'cyber-feature-rail__reviewer--active': reviewerIsActive,
'cyber-feature-rail__reviewer--sending': reviewerIsSender,
'cyber-feature-rail__reviewer--receiving': reviewerIsReceiver,
}"
aria-hidden="true"
>
<Transition name="cyber-feature-bubble">
<RobotSpeechBubble
v-if="reviewerBubbleText"
class="cyber-feature-rail__reviewer-bubble"
tail="down"
>
{{ reviewerBubbleText }}
</RobotSpeechBubble>
</Transition>
<div class="cyber-feature-rail__reviewer-card cyber-panel">
<div class="cyber-feature-rail__reviewer-label">{{ localizedHeroReviewerFeatureCard.label }}</div>
<ul class="cyber-feature-rail__reviewer-tasks">
<li v-for="task in localizedHeroReviewerFeatureCard.tasks" :key="task">{{ task }}</li>
</ul>
<div class="cyber-feature-rail__reviewer-status">
<span>{{ statusLabel }}</span>
<strong>{{ localizedHeroReviewerFeatureCard.status }}</strong>
</div>
</div>
<img
class="cyber-feature-rail__robot"
:src="localizedHeroReviewerFeatureCard.asset"
alt=""
loading="eager"
fetchpriority="high"
decoding="async"
>
</div>
<div class="cyber-feature-rail"> <div class="cyber-feature-rail">
<div <div
v-for="(feature, index) in localizedHeroFeatureRail" v-for="(feature, index) in localizedHeroFeatureRail"

View file

@ -55,7 +55,7 @@ const ruNotes: Record<string, string> = {
'5 columns, real-time': '5 колонок, в реальном времени', '5 columns, real-time': '5 колонок, в реальном времени',
'Dashboard, not Kanban': 'Панель, не канбан', 'Dashboard, not Kanban': 'Панель, не канбан',
'7 columns, drag-and-drop': '7 колонок, перетаскивание', '7 columns, drag-and-drop': '7 колонок, перетаскивание',
'Вызовы tools, reasoning, timeline': 'Вызовы tools, reasoning, timeline', 'Инструменты, ход рассуждений и таймлайн': 'Инструменты, ход рассуждений и таймлайн',
'Feed, metrics, dashboard': 'Лента, метрики, панель', 'Feed, metrics, dashboard': 'Лента, метрики, панель',
'Agent chat + terminal': 'Чат агента и терминал', 'Agent chat + terminal': 'Чат агента и терминал',
'View, stop, open URLs': 'Просмотр, остановка, открытие URL', 'View, stop, open URLs': 'Просмотр, остановка, открытие URL',
@ -271,7 +271,7 @@ const rows = computed<ComparisonRow[]>(() => [
}, },
{ {
feature: t('comparison.features.execLog'), feature: t('comparison.features.execLog'),
us: { status: 'yes', note: note('Вызовы tools, reasoning, timeline') }, us: { status: 'yes', note: note('Инструменты, ход рассуждений и таймлайн') },
gastown: { status: 'partial', note: note('Feed, metrics, dashboard') }, gastown: { status: 'partial', note: note('Feed, metrics, dashboard') },
paperclip: { status: 'yes', note: note('Run transcripts + audit log') }, paperclip: { status: 'yes', note: note('Run transcripts + audit log') },
cursor: { status: 'partial', note: note('Agent chat + terminal') }, cursor: { status: 'partial', note: note('Agent chat + terminal') },

View file

@ -252,6 +252,9 @@ onUnmounted(() => {
<CyberHeroFeatureStrip <CyberHeroFeatureStrip
class="cyber-hero__feature-strip" class="cyber-hero__feature-strip"
:active-message="activeHeroMessage"
:phase="heroMessagePhase"
:reduced-motion="heroReducedMotion"
/> />
</v-container> </v-container>
</section> </section>

View file

@ -8,7 +8,7 @@ export type Screenshot = {
/** /**
* Screenshot definitions for the carousel. * Screenshot definitions for the carousel.
* `src` is relative to public/ prepend baseURL at runtime. * `src` is served from the repository-level docs/screenshots directory.
*/ */
export const screenshots: (Omit<Screenshot, "src"> & { path: string })[] = [ export const screenshots: (Omit<Screenshot, "src"> & { path: string })[] = [
{ path: "screenshots/1.jpg", alt: "Kanban board with agent tasks", ruAlt: "Канбан-доска с задачами агентов", width: 1920, height: 1080 }, { path: "screenshots/1.jpg", alt: "Kanban board with agent tasks", ruAlt: "Канбан-доска с задачами агентов", width: 1920, height: 1080 },
@ -21,6 +21,4 @@ export const screenshots: (Omit<Screenshot, "src"> & { path: string })[] = [
{ path: "screenshots/8.png", alt: "Task details and comments", ruAlt: "Детали задачи и комментарии", width: 1920, height: 1080 }, { path: "screenshots/8.png", alt: "Task details and comments", ruAlt: "Детали задачи и комментарии", width: 1920, height: 1080 },
{ path: "screenshots/9.png", alt: "Built-in code editor", ruAlt: "Встроенный редактор кода", width: 1920, height: 1080 }, { path: "screenshots/9.png", alt: "Built-in code editor", ruAlt: "Встроенный редактор кода", width: 1920, height: 1080 },
{ path: "screenshots/10.png", alt: "Task details with code changes and execution logs", ruAlt: "Детали задачи с изменениями кода и логами выполнения", width: 2624, height: 1642 }, { path: "screenshots/10.png", alt: "Task details with code changes and execution logs", ruAlt: "Детали задачи с изменениями кода и логами выполнения", width: 2624, height: 1642 },
{ path: "screenshots/11.png", alt: "Agent code review comments and task workflow", ruAlt: "Комментарии агента к код-ревью и процессу задачи", width: 2624, height: 1696 },
{ path: "screenshots/12.png", alt: "Allow or deny agent actions with live preview", ruAlt: "Разрешение или запрет действий агента с предпросмотром", width: 2624, height: 1646 },
]; ];

View file

@ -1,3 +1,5 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import vuetify from "vite-plugin-vuetify"; import vuetify from "vite-plugin-vuetify";
import { generateI18nRoutes, supportedLocales } from "./data/i18n"; import { generateI18nRoutes, supportedLocales } from "./data/i18n";
@ -12,6 +14,7 @@ const muxPlaybackId = process.env.NUXT_PUBLIC_MUX_PLAYBACK_ID || "qyeNuDjFqoDALK
const muxBackgroundPlaybackId = process.env.NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID || muxPlaybackId; const muxBackgroundPlaybackId = process.env.NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID || muxPlaybackId;
const baseURL = process.env.NUXT_APP_BASE_URL || "/"; const baseURL = process.env.NUXT_APP_BASE_URL || "/";
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`; const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const defaultSeoTitle = "Agent Teams - AI Agent Orchestration for Developers"; 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 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-v5.png`;
@ -82,6 +85,13 @@ export default defineNuxtConfig({
}, },
nitro: { nitro: {
compressPublicAssets: true, compressPublicAssets: true,
publicAssets: [
{
baseURL: "/screenshots",
dir: resolve(repoRoot, "docs/screenshots"),
maxAge: 60 * 60 * 24 * 365
}
],
prerender: { prerender: {
ignore: [ ignore: [
"/docs", "/docs",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 KiB

View file

@ -1,7 +1,7 @@
{ {
"name": "agent-teams-ai", "name": "agent-teams-ai",
"type": "module", "type": "module",
"version": "2.0.0", "version": "2.1.1",
"description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows", "description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"author": { "author": {

View file

@ -1,27 +1,27 @@
{ {
"version": "0.0.45", "version": "0.0.46",
"sourceRef": "v0.0.45", "sourceRef": "v0.0.46",
"sourceRepository": "777genius/agent_teams_orchestrator", "sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai", "releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v2.1.0", "releaseTag": "v2.1.1",
"assets": { "assets": {
"darwin-arm64": { "darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.45.tar.gz", "file": "agent-teams-runtime-darwin-arm64-v0.0.46.tar.gz",
"archiveKind": "tar.gz", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"darwin-x64": { "darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.45.tar.gz", "file": "agent-teams-runtime-darwin-x64-v0.0.46.tar.gz",
"archiveKind": "tar.gz", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"linux-x64": { "linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.45.tar.gz", "file": "agent-teams-runtime-linux-x64-v0.0.46.tar.gz",
"archiveKind": "tar.gz", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"win32-x64": { "win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.45.zip", "file": "agent-teams-runtime-win32-x64-v0.0.46.zip",
"archiveKind": "zip", "archiveKind": "zip",
"binaryName": "claude-multimodel.exe" "binaryName": "claude-multimodel.exe"
} }

View file

@ -193,6 +193,7 @@ export type RuntimeProviderManagementErrorCodeDto =
| 'unsupported-runtime' | 'unsupported-runtime'
| 'unsupported-action' | 'unsupported-action'
| 'runtime-missing' | 'runtime-missing'
| 'runtime-misconfigured'
| 'runtime-unhealthy' | 'runtime-unhealthy'
| 'provider-missing' | 'provider-missing'
| 'auth-required' | 'auth-required'
@ -201,10 +202,24 @@ export type RuntimeProviderManagementErrorCodeDto =
| 'model-test-failed' | 'model-test-failed'
| 'unsupported-auth-method'; | 'unsupported-auth-method';
export interface RuntimeProviderManagementErrorDiagnosticsDto {
errorCode?: RuntimeProviderManagementErrorCodeDto | null;
summary: string | null;
likelyCause: string | null;
binaryPath: string | null;
command: string | null;
projectPath: string | null;
exitCode: number | null;
stderrPreview: string | null;
stdoutPreview: string | null;
hints: readonly string[];
}
export interface RuntimeProviderManagementErrorDto { export interface RuntimeProviderManagementErrorDto {
code: RuntimeProviderManagementErrorCodeDto; code: RuntimeProviderManagementErrorCodeDto;
message: string; message: string;
recoverable: boolean; recoverable: boolean;
diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null;
} }
export interface RuntimeProviderManagementViewResponse { export interface RuntimeProviderManagementViewResponse {

View file

@ -16,6 +16,7 @@ import type {
RuntimeProviderManagementConnectApiKeyInput, RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput, RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementErrorDto,
RuntimeProviderManagementForgetInput, RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput, RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadModelsInput,
@ -32,6 +33,85 @@ import type {
import type { IpcMain } from 'electron'; import type { IpcMain } from 'electron';
const logger = createLogger('Feature:RuntimeProviderManagement:IPC'); const logger = createLogger('Feature:RuntimeProviderManagement:IPC');
const RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT = 1_600;
const ESCAPE_CHARACTER = String.fromCharCode(27);
const BELL_CHARACTER = String.fromCharCode(7);
const ANSI_ESCAPE_PATTERN = new RegExp(`${ESCAPE_CHARACTER}\\[[0-?]*[ -/]*[@-~]`, 'g');
const OSC_ESCAPE_PATTERN = new RegExp(
`${ESCAPE_CHARACTER}\\][\\s\\S]*?(?:${BELL_CHARACTER}|${ESCAPE_CHARACTER}\\\\)`,
'g'
);
function truncateRuntimeProviderIpcErrorDetail(message: string): string {
if (message.length <= RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT) {
return message;
}
return `${message.slice(0, RUNTIME_PROVIDER_IPC_ERROR_DETAIL_LIMIT).trimEnd()}...`;
}
function sanitizeRuntimeProviderIpcErrorMessage(message: string): string {
const sanitized = message
.replace(OSC_ESCAPE_PATTERN, '')
.replace(ANSI_ESCAPE_PATTERN, '')
.replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, 'sk-...redacted')
.replace(/\b(or-[A-Za-z0-9_-]{12,})\b/g, 'or-...redacted')
.replace(/\b(AIza[A-Za-z0-9_-]{20,})\b/g, 'AIza...redacted')
.replace(
/\b([a-z0-9_.-]*(?:api[-_]?key|(?:access|auth)[-_]?token|token|secret|password|[-_]key)["'\s:=]+)([a-z0-9._~+/=-]{12,})/gi,
'$1...redacted'
)
.replace(/\b(key["'\s:=]+)([a-z0-9._~+/=-]{12,})/gi, '$1...redacted')
.replace(/\b(bearer\s+)([a-z0-9._~+/=-]{12,})/gi, '$1...redacted')
.trim();
return truncateRuntimeProviderIpcErrorDetail(sanitized);
}
function getRuntimeProviderIpcErrorMessage(error: unknown, fallback: string): string {
if (typeof error === 'string') {
return sanitizeRuntimeProviderIpcErrorMessage(error) || fallback;
}
if (!(error instanceof Error) || !error.message.trim()) {
return fallback;
}
return sanitizeRuntimeProviderIpcErrorMessage(error.message) || fallback;
}
function getRuntimeProviderIpcConnectLogDetail(error: unknown): string {
if (error instanceof Error) {
return sanitizeRuntimeProviderIpcErrorMessage(error.message) || error.name || 'Error';
}
if (typeof error === 'string') {
return sanitizeRuntimeProviderIpcErrorMessage(error) || 'Non-Error throw';
}
return 'Non-Error throw';
}
function createUnexpectedRuntimeProviderIpcError(
code: RuntimeProviderManagementErrorDto['code'],
message: string
): RuntimeProviderManagementErrorDto {
return {
code,
message,
recoverable: true,
diagnostics: {
errorCode: code,
summary: message,
likelyCause:
'The desktop app runtime provider management handler failed before it returned a normal response.',
binaryPath: null,
command: null,
projectPath: null,
exitCode: null,
stderrPreview: message,
stdoutPreview: null,
hints: [
'Retry the action once after refreshing provider settings.',
'If it repeats, copy diagnostics and attach the app logs from the same session.',
],
},
};
}
export function registerRuntimeProviderManagementIpc( export function registerRuntimeProviderManagementIpc(
ipcMain: IpcMain, ipcMain: IpcMain,
@ -46,15 +126,12 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.loadView(input); return await feature.loadView(input);
} catch (error) { } catch (error) {
logger.error('Failed to load runtime provider management view', error); const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to load providers');
logger.error('Failed to load runtime provider management view', message);
return { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load providers',
recoverable: true,
},
}; };
} }
} }
@ -69,15 +146,15 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.loadProviderDirectory(input); return await feature.loadProviderDirectory(input);
} catch (error) { } catch (error) {
logger.error('Failed to load runtime provider directory', error); const message = getRuntimeProviderIpcErrorMessage(
error,
'Failed to load provider directory'
);
logger.error('Failed to load runtime provider directory', message);
return { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider directory',
recoverable: true,
},
}; };
} }
} }
@ -92,15 +169,15 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.loadSetupForm(input); return await feature.loadSetupForm(input);
} catch (error) { } catch (error) {
logger.error('Failed to load runtime provider setup form', error); const message = getRuntimeProviderIpcErrorMessage(
error,
'Failed to load provider setup form'
);
logger.error('Failed to load runtime provider setup form', message);
return { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider setup form',
recoverable: true,
},
}; };
} }
} }
@ -115,18 +192,15 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.connectProvider(input); return await feature.connectProvider(input);
} catch (error) { } catch (error) {
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider');
logger.error( logger.error(
'Failed to connect runtime provider', 'Failed to connect runtime provider',
error instanceof Error ? error.name : error getRuntimeProviderIpcConnectLogDetail(error)
); );
return { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('auth-failed', message),
code: 'auth-failed',
message: 'Failed to connect provider',
recoverable: true,
},
}; };
} }
} }
@ -141,18 +215,15 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.connectWithApiKey(input); return await feature.connectWithApiKey(input);
} catch (error) { } catch (error) {
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider');
logger.error( logger.error(
'Failed to connect runtime provider', 'Failed to connect runtime provider',
error instanceof Error ? error.name : error getRuntimeProviderIpcConnectLogDetail(error)
); );
return { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('auth-failed', message),
code: 'auth-failed',
message: 'Failed to connect provider',
recoverable: true,
},
}; };
} }
} }
@ -167,15 +238,12 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.forgetCredential(input); return await feature.forgetCredential(input);
} catch (error) { } catch (error) {
logger.error('Failed to forget runtime provider credential', error); const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to forget provider');
logger.error('Failed to forget runtime provider credential', message);
return { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('unsupported-action', message),
code: 'unsupported-action',
message: error instanceof Error ? error.message : 'Failed to forget provider',
recoverable: true,
},
}; };
} }
} }
@ -190,15 +258,12 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.loadModels(input); return await feature.loadModels(input);
} catch (error) { } catch (error) {
logger.error('Failed to load runtime provider models', error); const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to load provider models');
logger.error('Failed to load runtime provider models', message);
return { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider models',
recoverable: true,
},
}; };
} }
} }
@ -213,15 +278,12 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.testModel(input); return await feature.testModel(input);
} catch (error) { } catch (error) {
logger.error('Failed to test runtime provider model', error); const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to test model');
logger.error('Failed to test runtime provider model', message);
return { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message),
code: 'model-test-failed',
message: error instanceof Error ? error.message : 'Failed to test model',
recoverable: true,
},
}; };
} }
} }
@ -236,15 +298,12 @@ export function registerRuntimeProviderManagementIpc(
try { try {
return await feature.setDefaultModel(input); return await feature.setDefaultModel(input);
} catch (error) { } catch (error) {
logger.error('Failed to set runtime provider default model', error); const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to set default model');
logger.error('Failed to set runtime provider default model', message);
return { return {
schemaVersion: 1, schemaVersion: 1,
runtimeId: input.runtimeId, runtimeId: input.runtimeId,
error: { error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message),
code: 'model-test-failed',
message: error instanceof Error ? error.message : 'Failed to set default model',
recoverable: true,
},
}; };
} }
} }

View file

@ -13,6 +13,7 @@ import type {
RuntimeProviderDefaultScopeDto, RuntimeProviderDefaultScopeDto,
RuntimeProviderDirectoryEntryDto, RuntimeProviderDirectoryEntryDto,
RuntimeProviderDirectoryFilterDto, RuntimeProviderDirectoryFilterDto,
RuntimeProviderManagementErrorDiagnosticsDto,
RuntimeProviderManagementRuntimeId, RuntimeProviderManagementRuntimeId,
RuntimeProviderManagementViewDto, RuntimeProviderManagementViewDto,
RuntimeProviderModelDto, RuntimeProviderModelDto,
@ -46,6 +47,7 @@ export interface RuntimeProviderManagementState {
directoryLoading: boolean; directoryLoading: boolean;
directoryRefreshing: boolean; directoryRefreshing: boolean;
directoryError: string | null; directoryError: string | null;
directoryErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
directoryEntries: readonly RuntimeProviderDirectoryEntryDto[]; directoryEntries: readonly RuntimeProviderDirectoryEntryDto[];
directoryTotalCount: number | null; directoryTotalCount: number | null;
directoryNextCursor: string | null; directoryNextCursor: string | null;
@ -56,7 +58,9 @@ export interface RuntimeProviderManagementState {
setupForm: RuntimeProviderSetupFormDto | null; setupForm: RuntimeProviderSetupFormDto | null;
setupFormLoading: boolean; setupFormLoading: boolean;
setupFormError: string | null; setupFormError: string | null;
setupFormErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
setupSubmitError: string | null; setupSubmitError: string | null;
setupSubmitErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
setupMetadata: Readonly<Record<string, string>>; setupMetadata: Readonly<Record<string, string>>;
apiKeyValue: string; apiKeyValue: string;
modelPickerProviderId: string | null; modelPickerProviderId: string | null;
@ -65,6 +69,7 @@ export interface RuntimeProviderManagementState {
models: readonly RuntimeProviderModelDto[]; models: readonly RuntimeProviderModelDto[];
modelsLoading: boolean; modelsLoading: boolean;
modelsError: string | null; modelsError: string | null;
modelsErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
selectedModelId: string | null; selectedModelId: string | null;
testingModelIds: readonly string[]; testingModelIds: readonly string[];
savingDefaultModelId: string | null; savingDefaultModelId: string | null;
@ -72,6 +77,7 @@ export interface RuntimeProviderManagementState {
loading: boolean; loading: boolean;
savingProviderId: string | null; savingProviderId: string | null;
error: string | null; error: string | null;
errorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
successMessage: string | null; successMessage: string | null;
} }
@ -219,6 +225,8 @@ export function useRuntimeProviderManagement(
const [directoryLoading, setDirectoryLoading] = useState(false); const [directoryLoading, setDirectoryLoading] = useState(false);
const [directoryRefreshing, setDirectoryRefreshing] = useState(false); const [directoryRefreshing, setDirectoryRefreshing] = useState(false);
const [directoryError, setDirectoryError] = useState<string | null>(null); const [directoryError, setDirectoryError] = useState<string | null>(null);
const [directoryErrorDiagnostics, setDirectoryErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [directoryEntries, setDirectoryEntries] = useState< const [directoryEntries, setDirectoryEntries] = useState<
readonly RuntimeProviderDirectoryEntryDto[] readonly RuntimeProviderDirectoryEntryDto[]
>([]); >([]);
@ -234,7 +242,11 @@ export function useRuntimeProviderManagement(
const [setupForm, setSetupForm] = useState<RuntimeProviderSetupFormDto | null>(null); const [setupForm, setSetupForm] = useState<RuntimeProviderSetupFormDto | null>(null);
const [setupFormLoading, setSetupFormLoading] = useState(false); const [setupFormLoading, setSetupFormLoading] = useState(false);
const [setupFormError, setSetupFormError] = useState<string | null>(null); const [setupFormError, setSetupFormError] = useState<string | null>(null);
const [setupFormErrorDiagnostics, setSetupFormErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [setupSubmitError, setSetupSubmitError] = useState<string | null>(null); const [setupSubmitError, setSetupSubmitError] = useState<string | null>(null);
const [setupSubmitErrorDiagnostics, setSetupSubmitErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [setupMetadata, setSetupMetadata] = useState<Record<string, string>>({}); const [setupMetadata, setSetupMetadata] = useState<Record<string, string>>({});
const [apiKeyValue, setApiKeyValue] = useState(''); const [apiKeyValue, setApiKeyValue] = useState('');
const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null); const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null);
@ -245,6 +257,8 @@ export function useRuntimeProviderManagement(
const [models, setModels] = useState<readonly RuntimeProviderModelDto[]>([]); const [models, setModels] = useState<readonly RuntimeProviderModelDto[]>([]);
const [modelsLoading, setModelsLoading] = useState(false); const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null); const [modelsError, setModelsError] = useState<string | null>(null);
const [modelsErrorDiagnostics, setModelsErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null); const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [testingModelIds, setTestingModelIds] = useState<readonly string[]>([]); const [testingModelIds, setTestingModelIds] = useState<readonly string[]>([]);
const [savingDefaultModelId, setSavingDefaultModelId] = useState<string | null>(null); const [savingDefaultModelId, setSavingDefaultModelId] = useState<string | null>(null);
@ -254,6 +268,8 @@ export function useRuntimeProviderManagement(
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [savingProviderId, setSavingProviderId] = useState<string | null>(null); const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [errorDiagnostics, setErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const viewLoadRequestSeq = useRef(0); const viewLoadRequestSeq = useRef(0);
const directoryRequestSeq = useRef(0); const directoryRequestSeq = useRef(0);
@ -296,6 +312,7 @@ export function useRuntimeProviderManagement(
setModels([]); setModels([]);
setModelsLoading(false); setModelsLoading(false);
setModelsError(null); setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null); setSelectedModelId(null);
setModelResults({}); setModelResults({});
setTestingModelIds([]); setTestingModelIds([]);
@ -313,6 +330,7 @@ export function useRuntimeProviderManagement(
setModels([]); setModels([]);
setModelsLoading(false); setModelsLoading(false);
setModelsError(null); setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null); setSelectedModelId(null);
setModelResults({}); setModelResults({});
setTestingModelIds([]); setTestingModelIds([]);
@ -323,24 +341,31 @@ export function useRuntimeProviderManagement(
setupFormRequestSeq.current += 1; setupFormRequestSeq.current += 1;
modelLoadRequestSeq.current += 1; modelLoadRequestSeq.current += 1;
modelProbeGenerationRef.current += 1; modelProbeGenerationRef.current += 1;
setDirectoryLoading(false);
setDirectoryRefreshing(false);
setDirectoryEntries([]); setDirectoryEntries([]);
setDirectoryTotalCount(null); setDirectoryTotalCount(null);
setDirectoryNextCursor(null); setDirectoryNextCursor(null);
setDirectoryError(null); setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectorySelectedProviderId(null); setDirectorySelectedProviderId(null);
setDirectoryLoaded(false); setDirectoryLoaded(false);
setSetupForm(null); setSetupForm(null);
setSetupFormLoading(false); setSetupFormLoading(false);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setActiveFormProviderId(null); setActiveFormProviderId(null);
setApiKeyValue(''); setApiKeyValue('');
setSetupMetadata({}); setSetupMetadata({});
setModels([]); setModels([]);
setModelsLoading(false); setModelsLoading(false);
setModelsError(null); setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null); setSelectedModelId(null);
setTestingModelIds([]); setTestingModelIds([]);
setSavingProviderId(null);
setSavingDefaultModelId(null); setSavingDefaultModelId(null);
setModelResults({}); setModelResults({});
setSuccessMessage(null); setSuccessMessage(null);
@ -361,6 +386,7 @@ export function useRuntimeProviderManagement(
setLoading(true); setLoading(true);
} }
setError(null); setError(null);
setErrorDiagnostics(null);
try { try {
const response = await api.runtimeProviderManagement.loadView({ const response = await api.runtimeProviderManagement.loadView({
runtimeId: options.runtimeId, runtimeId: options.runtimeId,
@ -374,6 +400,7 @@ export function useRuntimeProviderManagement(
setView(null); setView(null);
} }
setError(response.error.message); setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
const nextView = response.view ?? null; const nextView = response.view ?? null;
@ -392,6 +419,7 @@ export function useRuntimeProviderManagement(
setView(null); setView(null);
} }
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers'); setError(loadError instanceof Error ? loadError.message : 'Failed to load providers');
setErrorDiagnostics(null);
} finally { } finally {
if (!silent && requestIsCurrent()) { if (!silent && requestIsCurrent()) {
setLoading(false); setLoading(false);
@ -434,6 +462,7 @@ export function useRuntimeProviderManagement(
setDirectoryLoading(true); setDirectoryLoading(true);
} }
setDirectoryError(null); setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
try { try {
const response = await api.runtimeProviderManagement.loadProviderDirectory({ const response = await api.runtimeProviderManagement.loadProviderDirectory({
@ -450,6 +479,7 @@ export function useRuntimeProviderManagement(
} }
if (response.error) { if (response.error) {
setDirectoryError(response.error.message); setDirectoryError(response.error.message);
setDirectoryErrorDiagnostics(response.error.diagnostics ?? null);
if ( if (
response.error.code === 'unsupported-action' || response.error.code === 'unsupported-action' ||
response.error.message.toLowerCase().includes('unknown command') response.error.message.toLowerCase().includes('unknown command')
@ -461,6 +491,7 @@ export function useRuntimeProviderManagement(
const directory = response.directory; const directory = response.directory;
if (!directory) { if (!directory) {
setDirectoryError('Provider directory response was empty'); setDirectoryError('Provider directory response was empty');
setDirectoryErrorDiagnostics(null);
return; return;
} }
setDirectoryLoaded(true); setDirectoryLoaded(true);
@ -474,6 +505,7 @@ export function useRuntimeProviderManagement(
setDirectoryError( setDirectoryError(
loadError instanceof Error ? loadError.message : 'Failed to load provider directory' loadError instanceof Error ? loadError.message : 'Failed to load provider directory'
); );
setDirectoryErrorDiagnostics(null);
} }
} finally { } finally {
if (requestIsCurrent()) { if (requestIsCurrent()) {
@ -495,23 +527,37 @@ export function useRuntimeProviderManagement(
useEffect(() => { useEffect(() => {
if (!options.enabled) { if (!options.enabled) {
viewLoadRequestSeq.current += 1; viewLoadRequestSeq.current += 1;
directoryRequestSeq.current += 1;
setupFormRequestSeq.current += 1;
appliedInitialProviderRef.current = null; appliedInitialProviderRef.current = null;
setView(null);
setSelectedProviderId(null);
setProviderQuery(''); setProviderQuery('');
setLoading(false);
setSavingProviderId(null);
setSavingDefaultModelId(null);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
setDirectoryLoading(false); setDirectoryLoading(false);
setDirectoryRefreshing(false); setDirectoryRefreshing(false);
setDirectoryError(null); setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectoryEntries([]); setDirectoryEntries([]);
setDirectoryTotalCount(null); setDirectoryTotalCount(null);
setDirectoryNextCursor(null); setDirectoryNextCursor(null);
setDirectoryQuery(''); setDirectoryQuery('');
setDirectoryLoaded(false); setDirectoryLoaded(false);
setDirectorySelectedProviderId(null); setDirectorySelectedProviderId(null);
setDirectorySupported(true);
setApiKeyValue(''); setApiKeyValue('');
setSetupMetadata({}); setSetupMetadata({});
setSetupForm(null); setSetupForm(null);
setSetupFormLoading(false); setSetupFormLoading(false);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setActiveFormProviderId(null); setActiveFormProviderId(null);
closeModelPickerState(); closeModelPickerState();
return; return;
@ -537,12 +583,20 @@ export function useRuntimeProviderManagement(
); );
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [directoryLoaded, directoryQuery, directorySupported, loadDirectoryPage, options.enabled]); }, [
currentProjectPath,
directoryLoaded,
directoryQuery,
directorySupported,
loadDirectoryPage,
options.enabled,
]);
useEffect(() => { useEffect(() => {
if (!options.enabled || !modelPickerProviderId) { if (!options.enabled || !modelPickerProviderId) {
modelLoadRequestSeq.current += 1; modelLoadRequestSeq.current += 1;
setModelsLoading(false); setModelsLoading(false);
setModelsErrorDiagnostics(null);
return; return;
} }
@ -557,6 +611,7 @@ export function useRuntimeProviderManagement(
let cancelled = false; let cancelled = false;
setModelsLoading(true); setModelsLoading(true);
setModelsError(null); setModelsError(null);
setModelsErrorDiagnostics(null);
void withUiTimeout( void withUiTimeout(
api.runtimeProviderManagement.loadModels({ api.runtimeProviderManagement.loadModels({
runtimeId: options.runtimeId, runtimeId: options.runtimeId,
@ -574,6 +629,7 @@ export function useRuntimeProviderManagement(
if (response.error) { if (response.error) {
setModels([]); setModels([]);
setModelsError(response.error.message); setModelsError(response.error.message);
setModelsErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
const nextModels = response.models?.models ?? []; const nextModels = response.models?.models ?? [];
@ -593,6 +649,7 @@ export function useRuntimeProviderManagement(
? modelsLoadError.message ? modelsLoadError.message
: 'Failed to load provider models' : 'Failed to load provider models'
); );
setModelsErrorDiagnostics(null);
} }
}) })
.finally(() => { .finally(() => {
@ -678,7 +735,9 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null); setActiveFormProviderId(null);
setSetupForm(null); setSetupForm(null);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupMetadata({}); setSetupMetadata({});
setApiKeyValue(''); setApiKeyValue('');
@ -704,6 +763,7 @@ export function useRuntimeProviderManagement(
const searchAllProviders = useCallback((query: string): void => { const searchAllProviders = useCallback((query: string): void => {
setDirectoryQuery(query); setDirectoryQuery(query);
setDirectoryError(null); setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectoryNextCursor(null); setDirectoryNextCursor(null);
}, []); }, []);
@ -716,9 +776,12 @@ export function useRuntimeProviderManagement(
setSetupMetadata({}); setSetupMetadata({});
setSetupForm(null); setSetupForm(null);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupFormLoading(true); setSetupFormLoading(true);
setError(null); setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
const projectContext = getProjectContextSnapshot(); const projectContext = getProjectContextSnapshot();
const requestSeq = setupFormRequestSeq.current + 1; const requestSeq = setupFormRequestSeq.current + 1;
@ -740,11 +803,13 @@ export function useRuntimeProviderManagement(
} }
if (response.error) { if (response.error) {
setSetupFormError(response.error.message); setSetupFormError(response.error.message);
setSetupFormErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
setSetupForm(response.setupForm ?? null); setSetupForm(response.setupForm ?? null);
if (!response.setupForm) { if (!response.setupForm) {
setSetupFormError('Provider setup form response was empty'); setSetupFormError('Provider setup form response was empty');
setSetupFormErrorDiagnostics(null);
} }
}) })
.catch((setupError) => { .catch((setupError) => {
@ -754,6 +819,7 @@ export function useRuntimeProviderManagement(
setSetupFormError( setSetupFormError(
setupError instanceof Error ? setupError.message : 'Failed to load provider setup form' setupError instanceof Error ? setupError.message : 'Failed to load provider setup form'
); );
setSetupFormErrorDiagnostics(null);
}) })
.finally(() => { .finally(() => {
if (requestIsCurrent()) { if (requestIsCurrent()) {
@ -784,13 +850,17 @@ export function useRuntimeProviderManagement(
setSetupForm(null); setSetupForm(null);
setSetupFormLoading(false); setSetupFormLoading(false);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setError(null); setError(null);
setErrorDiagnostics(null);
}, []); }, []);
const updateApiKeyValue = useCallback((value: string): void => { const updateApiKeyValue = useCallback((value: string): void => {
setApiKeyValue(value); setApiKeyValue(value);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
}, []); }, []);
const setSetupMetadataValue = useCallback((key: string, value: string): void => { const setSetupMetadataValue = useCallback((key: string, value: string): void => {
@ -799,29 +869,35 @@ export function useRuntimeProviderManagement(
[key]: value, [key]: value,
})); }));
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
}, []); }, []);
const submitConnect = useCallback( const submitConnect = useCallback(
async (providerId: string): Promise<void> => { async (providerId: string): Promise<void> => {
if (!setupForm) { if (!setupForm) {
setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded'); setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded');
setSetupSubmitErrorDiagnostics(setupFormErrorDiagnostics ?? null);
return; return;
} }
if (!setupForm.supported) { if (!setupForm.supported) {
setSetupSubmitError( setSetupSubmitError(
setupForm.disabledReason ?? 'Provider setup is not supported in the app' setupForm.disabledReason ?? 'Provider setup is not supported in the app'
); );
setSetupSubmitErrorDiagnostics(null);
return; return;
} }
const apiKey = apiKeyValue.trim(); const apiKey = apiKeyValue.trim();
if (setupForm.secret?.required && !apiKey) { if (setupForm.secret?.required && !apiKey) {
setSetupSubmitError(`${setupForm.secret.label} is required`); setSetupSubmitError(`${setupForm.secret.label} is required`);
setSetupSubmitErrorDiagnostics(null);
return; return;
} }
setSavingProviderId(providerId); setSavingProviderId(providerId);
setError(null); setError(null);
setErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
const projectContext = getProjectContextSnapshot(); const projectContext = getProjectContextSnapshot();
try { try {
@ -841,6 +917,7 @@ export function useRuntimeProviderManagement(
} }
if (response.error) { if (response.error) {
setSetupSubmitError(response.error.message); setSetupSubmitError(response.error.message);
setSetupSubmitErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
if (response.provider) { if (response.provider) {
@ -852,7 +929,9 @@ export function useRuntimeProviderManagement(
setSetupMetadata({}); setSetupMetadata({});
setSetupForm(null); setSetupForm(null);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
try { try {
await options.onProviderChanged?.(); await options.onProviderChanged?.();
if (!isProjectContextCurrent(projectContext)) { if (!isProjectContextCurrent(projectContext)) {
@ -869,6 +948,7 @@ export function useRuntimeProviderManagement(
setError( setError(
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
); );
setErrorDiagnostics(null);
} }
} catch (connectError) { } catch (connectError) {
if (!isProjectContextCurrent(projectContext)) { if (!isProjectContextCurrent(projectContext)) {
@ -877,6 +957,7 @@ export function useRuntimeProviderManagement(
setSetupSubmitError( setSetupSubmitError(
connectError instanceof Error ? connectError.message : 'Failed to connect provider' connectError instanceof Error ? connectError.message : 'Failed to connect provider'
); );
setSetupSubmitErrorDiagnostics(null);
} finally { } finally {
if (isProjectContextCurrent(projectContext)) { if (isProjectContextCurrent(projectContext)) {
setSavingProviderId(null); setSavingProviderId(null);
@ -892,6 +973,7 @@ export function useRuntimeProviderManagement(
refresh, refresh,
setupForm, setupForm,
setupFormError, setupFormError,
setupFormErrorDiagnostics,
setupMetadata, setupMetadata,
] ]
); );
@ -900,6 +982,7 @@ export function useRuntimeProviderManagement(
async (providerId: string): Promise<void> => { async (providerId: string): Promise<void> => {
setSavingProviderId(providerId); setSavingProviderId(providerId);
setError(null); setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
const projectContext = getProjectContextSnapshot(); const projectContext = getProjectContextSnapshot();
try { try {
@ -916,6 +999,7 @@ export function useRuntimeProviderManagement(
} }
if (response.error) { if (response.error) {
setError(response.error.message); setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
if (response.provider) { if (response.provider) {
@ -938,6 +1022,7 @@ export function useRuntimeProviderManagement(
setError( setError(
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
); );
setErrorDiagnostics(null);
} }
if (!isProjectContextCurrent(projectContext)) { if (!isProjectContextCurrent(projectContext)) {
return; return;
@ -950,6 +1035,7 @@ export function useRuntimeProviderManagement(
setError( setError(
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential' forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
); );
setErrorDiagnostics(null);
} finally { } finally {
if (isProjectContextCurrent(projectContext)) { if (isProjectContextCurrent(projectContext)) {
setSavingProviderId(null); setSavingProviderId(null);
@ -965,6 +1051,7 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null); setActiveFormProviderId(null);
openModelPickerState(providerId, mode); openModelPickerState(providerId, mode);
setError(null); setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
}, },
[openModelPickerState] [openModelPickerState]
@ -979,6 +1066,7 @@ export function useRuntimeProviderManagement(
setSelectedModelId(modelId); setSelectedModelId(modelId);
setSuccessMessage(null); setSuccessMessage(null);
setError(null); setError(null);
setErrorDiagnostics(null);
}, []); }, []);
const testModel = useCallback( const testModel = useCallback(
@ -994,6 +1082,7 @@ export function useRuntimeProviderManagement(
current.includes(modelId) ? current : [...current, modelId] current.includes(modelId) ? current : [...current, modelId]
); );
setError(null); setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
try { try {
const response = await withUiTimeout( const response = await withUiTimeout(
@ -1007,6 +1096,10 @@ export function useRuntimeProviderManagement(
100_000 100_000
); );
if (response.error) { if (response.error) {
if (response.error.diagnostics && shouldRecordProbeResult()) {
setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics);
}
if (shouldRecordProbeResult()) { if (shouldRecordProbeResult()) {
const result = buildFailedModelTestResult(providerId, modelId, response.error.message); const result = buildFailedModelTestResult(providerId, modelId, response.error.message);
setModelResults((current) => ({ setModelResults((current) => ({
@ -1064,6 +1157,7 @@ export function useRuntimeProviderManagement(
): Promise<void> => { ): Promise<void> => {
setSavingDefaultModelId(modelId); setSavingDefaultModelId(modelId);
setError(null); setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null); setSuccessMessage(null);
const projectContext = getProjectContextSnapshot(); const projectContext = getProjectContextSnapshot();
try { try {
@ -1084,6 +1178,7 @@ export function useRuntimeProviderManagement(
} }
if (response.error) { if (response.error) {
setError(response.error.message); setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return; return;
} }
const proofResult: RuntimeProviderModelTestResultDto = { const proofResult: RuntimeProviderModelTestResultDto = {
@ -1130,6 +1225,7 @@ export function useRuntimeProviderManagement(
setError( setError(
defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default' defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default'
); );
setErrorDiagnostics(null);
} finally { } finally {
if (isProjectContextCurrent(projectContext)) { if (isProjectContextCurrent(projectContext)) {
setSavingDefaultModelId(null); setSavingDefaultModelId(null);
@ -1146,7 +1242,9 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null); setActiveFormProviderId(null);
setSetupForm(null); setSetupForm(null);
setSetupFormError(null); setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null); setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupMetadata({}); setSetupMetadata({});
setApiKeyValue(''); setApiKeyValue('');
if (activeModelPickerProviderRef.current !== providerId) { if (activeModelPickerProviderRef.current !== providerId) {
@ -1199,6 +1297,7 @@ export function useRuntimeProviderManagement(
directoryLoading, directoryLoading,
directoryRefreshing, directoryRefreshing,
directoryError, directoryError,
directoryErrorDiagnostics,
directoryEntries, directoryEntries,
directoryTotalCount, directoryTotalCount,
directoryNextCursor, directoryNextCursor,
@ -1209,7 +1308,9 @@ export function useRuntimeProviderManagement(
setupForm, setupForm,
setupFormLoading, setupFormLoading,
setupFormError, setupFormError,
setupFormErrorDiagnostics,
setupSubmitError, setupSubmitError,
setupSubmitErrorDiagnostics,
setupMetadata, setupMetadata,
apiKeyValue, apiKeyValue,
modelPickerProviderId, modelPickerProviderId,
@ -1218,6 +1319,7 @@ export function useRuntimeProviderManagement(
models, models,
modelsLoading, modelsLoading,
modelsError, modelsError,
modelsErrorDiagnostics,
selectedModelId, selectedModelId,
testingModelIds, testingModelIds,
savingDefaultModelId, savingDefaultModelId,
@ -1225,18 +1327,22 @@ export function useRuntimeProviderManagement(
loading, loading,
savingProviderId, savingProviderId,
error, error,
errorDiagnostics,
successMessage, successMessage,
}), }),
[ [
activeFormProviderId, activeFormProviderId,
apiKeyValue, apiKeyValue,
setupForm, setupForm,
setupFormErrorDiagnostics,
setupFormError, setupFormError,
setupFormLoading, setupFormLoading,
setupSubmitErrorDiagnostics,
setupSubmitError, setupSubmitError,
setupMetadata, setupMetadata,
directoryEntries, directoryEntries,
directoryError, directoryError,
directoryErrorDiagnostics,
directoryLoaded, directoryLoaded,
directoryLoading, directoryLoading,
directoryNextCursor, directoryNextCursor,
@ -1245,12 +1351,14 @@ export function useRuntimeProviderManagement(
directorySupported, directorySupported,
directoryTotalCount, directoryTotalCount,
error, error,
errorDiagnostics,
loading, loading,
modelPickerMode, modelPickerMode,
modelPickerProviderId, modelPickerProviderId,
modelQuery, modelQuery,
modelResults, modelResults,
models, models,
modelsErrorDiagnostics,
modelsError, modelsError,
modelsLoading, modelsLoading,
providerQuery, providerQuery,

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
@ -20,7 +20,9 @@ import {
} from '@renderer/utils/openCodeModelRecommendations'; } from '@renderer/utils/openCodeModelRecommendations';
import { import {
AlertTriangle, AlertTriangle,
Check,
CheckCircle2, CheckCircle2,
ClipboardList,
KeyRound, KeyRound,
Loader2, Loader2,
RefreshCcw, RefreshCcw,
@ -47,6 +49,7 @@ import type {
RuntimeProviderDefaultModelSourceDto, RuntimeProviderDefaultModelSourceDto,
RuntimeProviderDefaultScopeDto, RuntimeProviderDefaultScopeDto,
RuntimeProviderDirectoryEntryDto, RuntimeProviderDirectoryEntryDto,
RuntimeProviderManagementErrorDiagnosticsDto,
RuntimeProviderModelDto, RuntimeProviderModelDto,
RuntimeProviderModelTestResultDto, RuntimeProviderModelTestResultDto,
RuntimeProviderSetupPromptDto, RuntimeProviderSetupPromptDto,
@ -84,6 +87,12 @@ interface ProviderRowProps {
readonly actions: RuntimeProviderManagementActions; readonly actions: RuntimeProviderManagementActions;
} }
interface RuntimeProviderErrorAlertProps {
readonly message: string;
readonly diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null;
readonly testId: string;
}
type OpenCodeSettingsSection = 'models' | 'providers'; type OpenCodeSettingsSection = 'models' | 'providers';
const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__'; const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__';
@ -338,8 +347,11 @@ function ProviderSetupFormPanel({
const form = state.setupForm?.providerId === provider.providerId ? state.setupForm : null; const form = state.setupForm?.providerId === provider.providerId ? state.setupForm : null;
const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId; const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId;
const error = state.setupFormError; const error = state.setupFormError;
const errorDiagnostics = state.setupFormErrorDiagnostics;
const submitError = const submitError =
state.activeFormProviderId === provider.providerId ? state.setupSubmitError : null; state.activeFormProviderId === provider.providerId ? state.setupSubmitError : null;
const submitErrorDiagnostics =
state.activeFormProviderId === provider.providerId ? state.setupSubmitErrorDiagnostics : null;
const canSubmit = setupFormCanSubmit(state, provider.providerId); const canSubmit = setupFormCanSubmit(state, provider.providerId);
return ( return (
@ -356,9 +368,11 @@ function ProviderSetupFormPanel({
) : null} ) : null}
{!loading && error ? ( {!loading && error ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200"> <RuntimeProviderErrorAlert
{error} message={error}
</div> diagnostics={errorDiagnostics}
testId="runtime-provider-setup-form-error"
/>
) : null} ) : null}
{!loading && form ? ( {!loading && form ? (
@ -445,8 +459,12 @@ function ProviderSetupFormPanel({
) : null} ) : null}
{submitError ? ( {submitError ? (
<div className="mt-3 rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200"> <div className="mt-3">
{submitError} <RuntimeProviderErrorAlert
message={submitError}
diagnostics={submitErrorDiagnostics}
testId="runtime-provider-setup-submit-error"
/>
</div> </div>
) : null} ) : null}
@ -668,6 +686,228 @@ function RuntimeProviderLoadingPlaceholder(): JSX.Element {
); );
} }
function formatRuntimeProviderDiagnosticsCopyText(
message: string,
diagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null | undefined
): string {
const lines = ['OpenCode provider settings diagnostics', '', 'Message:', message.trim()];
if (!diagnostics) {
return lines.join('\n');
}
const hints = diagnostics.hints ?? [];
const fields: Array<[string, string | number | null]> = [
['Error code', diagnostics.errorCode ?? null],
['Summary', diagnostics.summary],
['Likely cause', diagnostics.likelyCause],
['Resolved runtime binary', diagnostics.binaryPath],
['Command', diagnostics.command],
['Project path', diagnostics.projectPath],
['Exit code', diagnostics.exitCode],
];
lines.push('', 'Structured diagnostics:');
for (const [label, value] of fields) {
if (value !== null && value !== '') {
lines.push(`${label}: ${String(value)}`);
}
}
if (hints.length > 0) {
lines.push('', 'Hints:', ...hints.map((hint) => `- ${hint}`));
}
if (diagnostics.stderrPreview) {
lines.push('', 'stderr preview:', diagnostics.stderrPreview);
}
if (diagnostics.stdoutPreview) {
lines.push('', 'stdout preview:', diagnostics.stdoutPreview);
}
return lines.join('\n');
}
function getRuntimeProviderDiagnosticRows(
diagnostics: RuntimeProviderManagementErrorDiagnosticsDto
): Array<[string, string]> {
const rows: Array<[string, string | number | null]> = [
['Code', diagnostics.errorCode ?? null],
['Binary', diagnostics.binaryPath],
['Command', diagnostics.command],
['Project', diagnostics.projectPath],
['Exit', diagnostics.exitCode],
];
return rows
.filter(([, value]) => value !== null && value !== '')
.map(([label, value]) => [label, String(value)]);
}
async function writeRuntimeProviderDiagnosticsToClipboard(text: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall back to the selection API below.
}
}
return copyRuntimeProviderDiagnosticsWithSelection(text);
}
function copyRuntimeProviderDiagnosticsWithSelection(text: string): boolean {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
return document.execCommand('copy');
} catch {
return false;
} finally {
textarea.remove();
}
}
const RuntimeProviderErrorAlert = ({
message,
diagnostics = null,
testId,
}: RuntimeProviderErrorAlertProps): JSX.Element => {
const [copied, setCopied] = useState(false);
const [headline = message, ...detailLines] = message.trim().split(/\r?\n/);
const fallbackDetails = detailLines.join('\n').trim();
const hints = diagnostics?.hints ?? [];
const copyText = useMemo(
() => formatRuntimeProviderDiagnosticsCopyText(message, diagnostics),
[diagnostics, message]
);
const diagnosticRows = diagnostics ? getRuntimeProviderDiagnosticRows(diagnostics) : [];
const copyDiagnostics = useCallback(async (): Promise<void> => {
setCopied(await writeRuntimeProviderDiagnosticsToClipboard(copyText));
}, [copyText]);
useEffect(() => {
if (!copied) {
return;
}
const timeout = window.setTimeout(() => setCopied(false), 1_500);
return () => window.clearTimeout(timeout);
}, [copied]);
return (
<div
data-testid={testId}
role="alert"
className="flex min-w-0 items-start gap-2 rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(248, 113, 113, 0.25)',
backgroundColor: 'rgba(248, 113, 113, 0.06)',
color: '#fca5a5',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<div className="min-w-0 whitespace-pre-wrap break-words font-medium leading-5">
{headline || message}
</div>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 shrink-0 px-2 text-[11px]"
title={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
aria-label={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
onClick={(event) => {
event.stopPropagation();
void copyDiagnostics();
}}
>
{copied ? <Check className="mr-1 size-3" /> : <ClipboardList className="mr-1 size-3" />}
{copied ? 'Copied' : 'Copy diagnostics'}
</Button>
</div>
{diagnostics ? (
<div className="mt-2 space-y-2">
{diagnostics.likelyCause ? (
<div className="whitespace-pre-wrap break-words leading-5 text-red-100">
<span className="font-medium text-red-100">Likely cause: </span>
{diagnostics.likelyCause}
</div>
) : null}
{diagnosticRows.length > 0 ? (
<dl className="grid gap-1 rounded border px-2 py-1.5 text-[11px] leading-4 sm:grid-cols-[92px_minmax(0,1fr)]">
{diagnosticRows.map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-red-200/75">{label}</dt>
<dd className="min-w-0 break-words font-mono text-red-100">{value}</dd>
</div>
))}
</dl>
) : null}
{hints.length > 0 ? (
<div>
<div className="mb-1 font-medium text-red-100">Hints</div>
<ul className="space-y-1 pl-4">
{hints.map((hint, index) => (
<li
key={`${hint}-${index}`}
className="list-disc whitespace-pre-wrap break-words"
>
{hint}
</li>
))}
</ul>
</div>
) : null}
{diagnostics.stderrPreview ? (
<pre
data-testid={`${testId}-stderr-preview`}
className="m-0 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded border px-2 py-1.5 font-mono text-[11px] leading-4"
style={{
borderColor: 'rgba(248, 113, 113, 0.2)',
backgroundColor: 'rgba(15, 23, 42, 0.38)',
color: '#fecaca',
}}
>
{`stderr preview:\n${diagnostics.stderrPreview}`}
</pre>
) : null}
{diagnostics.stdoutPreview ? (
<pre
data-testid={`${testId}-stdout-preview`}
className="m-0 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded border px-2 py-1.5 font-mono text-[11px] leading-4"
style={{
borderColor: 'rgba(248, 113, 113, 0.2)',
backgroundColor: 'rgba(15, 23, 42, 0.38)',
color: '#fecaca',
}}
>
{`stdout preview:\n${diagnostics.stdoutPreview}`}
</pre>
) : null}
</div>
) : fallbackDetails ? (
<pre
className="m-0 mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border px-2 py-1.5 font-mono text-[11px] leading-4"
style={{
borderColor: 'rgba(248, 113, 113, 0.2)',
backgroundColor: 'rgba(15, 23, 42, 0.38)',
color: '#fecaca',
}}
>
{fallbackDetails}
</pre>
) : null}
</div>
</div>
);
};
function RuntimeProviderModelLoadingSkeleton(): JSX.Element { function RuntimeProviderModelLoadingSkeleton(): JSX.Element {
return ( return (
<div className="space-y-2" data-testid="runtime-provider-model-loading-skeleton"> <div className="space-y-2" data-testid="runtime-provider-model-loading-skeleton">
@ -1096,8 +1336,8 @@ function ModelBadges({
}): JSX.Element | null { }): JSX.Element | null {
const modelRecommendation = getOpenCodeTeamModelRecommendation(model.modelId); const modelRecommendation = getOpenCodeTeamModelRecommendation(model.modelId);
const localRoute = model.routeKind === 'configured_local'; const localRoute = model.routeKind === 'configured_local';
const builtinFreeRoute = model.routeKind === 'builtin_free';
const connectedRoute = model.routeKind === 'connected_provider'; const connectedRoute = model.routeKind === 'connected_provider';
const freeModel = isFreeRuntimeProviderModel(model);
const verified = const verified =
model.proofState === 'verified' || model.proofState === 'verified' ||
model.availability === 'available' || model.availability === 'available' ||
@ -1111,8 +1351,7 @@ function ModelBadges({
const unknown = model.accessKind === 'unknown_model' || model.accessKind === 'no_model'; const unknown = model.accessKind === 'unknown_model' || model.accessKind === 'no_model';
if ( if (
!model.free && !freeModel &&
!builtinFreeRoute &&
!model.default && !model.default &&
!usedForNewTeams && !usedForNewTeams &&
!modelRecommendation && !modelRecommendation &&
@ -1163,7 +1402,7 @@ function ModelBadges({
Used in team picker Used in team picker
</Badge> </Badge>
) : null} ) : null}
{model.free ? ( {freeModel ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200">free</Badge> <Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200">free</Badge>
) : null} ) : null}
{localRoute ? ( {localRoute ? (
@ -1172,9 +1411,6 @@ function ModelBadges({
<Badge className="bg-sky-400/15 px-1.5 py-0 text-[10px] text-sky-200">configured</Badge> <Badge className="bg-sky-400/15 px-1.5 py-0 text-[10px] text-sky-200">configured</Badge>
</> </>
) : null} ) : null}
{builtinFreeRoute && !model.free ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200">free</Badge>
) : null}
{connectedRoute ? ( {connectedRoute ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-100"> <Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-100">
connected connected
@ -1201,6 +1437,19 @@ function ModelBadges({
); );
} }
function isFreeRuntimeProviderModel(model: RuntimeProviderModelDto): boolean {
const normalizedModelId = model.modelId.trim().toLowerCase();
return (
model.free ||
model.routeKind === 'builtin_free' ||
model.accessKind === 'builtin_free' ||
normalizedModelId === 'opencode/big-pickle' ||
normalizedModelId.includes(':free') ||
normalizedModelId.endsWith('-free') ||
normalizedModelId.endsWith('/free')
);
}
function isUnknownOpenCodeModelRoute(model: RuntimeProviderModelDto): boolean { function isUnknownOpenCodeModelRoute(model: RuntimeProviderModelDto): boolean {
return model.accessKind === 'unknown_model' || model.accessKind === 'no_model'; return model.accessKind === 'unknown_model' || model.accessKind === 'no_model';
} }
@ -1245,7 +1494,7 @@ function getOpenCodeModelSearchText(model: RuntimeProviderModelDto): string {
model.proofState, model.proofState,
model.availability, model.availability,
model.accessReason ?? '', model.accessReason ?? '',
model.free ? 'free' : '', isFreeRuntimeProviderModel(model) ? 'free' : '',
model.default ? 'default' : '', model.default ? 'default' : '',
model.requiresExecutionProof ? 'needs test needs probe' : '', model.requiresExecutionProof ? 'needs test needs probe' : '',
recommendation?.label ?? '', recommendation?.label ?? '',
@ -1688,10 +1937,15 @@ function ProviderModelList({
}): JSX.Element { }): JSX.Element {
const pickerOpen = state.modelPickerProviderId === provider.providerId; const pickerOpen = state.modelPickerProviderId === provider.providerId;
const [recommendedOnly, setRecommendedOnly] = useState(false); const [recommendedOnly, setRecommendedOnly] = useState(false);
const [freeOnly, setFreeOnly] = useState(false);
const hasRecommendedModels = useMemo( const hasRecommendedModels = useMemo(
() => state.models.some((model) => isOpenCodeTeamModelRecommended(model.modelId)), () => state.models.some((model) => isOpenCodeTeamModelRecommended(model.modelId)),
[state.models] [state.models]
); );
const hasFreeModels = useMemo(
() => state.models.some((model) => isFreeRuntimeProviderModel(model)),
[state.models]
);
useEffect(() => { useEffect(() => {
if (!hasRecommendedModels) { if (!hasRecommendedModels) {
@ -1699,11 +1953,18 @@ function ProviderModelList({
} }
}, [hasRecommendedModels]); }, [hasRecommendedModels]);
useEffect(() => {
if (!hasFreeModels) {
setFreeOnly(false);
}
}, [hasFreeModels]);
const visibleModels = useMemo( const visibleModels = useMemo(
() => () =>
state.models state.models
.map((model, index) => ({ model, index })) .map((model, index) => ({ model, index }))
.filter(({ model }) => !recommendedOnly || isOpenCodeTeamModelRecommended(model.modelId)) .filter(({ model }) => !recommendedOnly || isOpenCodeTeamModelRecommended(model.modelId))
.filter(({ model }) => !freeOnly || isFreeRuntimeProviderModel(model))
.sort((left, right) => { .sort((left, right) => {
const recommendationOrder = compareOpenCodeTeamModelRecommendations( const recommendationOrder = compareOpenCodeTeamModelRecommendations(
left.model.modelId, left.model.modelId,
@ -1712,8 +1973,15 @@ function ProviderModelList({
return recommendationOrder || left.index - right.index; return recommendationOrder || left.index - right.index;
}) })
.map(({ model }) => model), .map(({ model }) => model),
[recommendedOnly, state.models] [freeOnly, recommendedOnly, state.models]
); );
const emptyModelListMessage = recommendedOnly
? freeOnly
? 'No recommended free models found.'
: 'No recommended models found.'
: freeOnly
? 'No free models found.'
: 'No models found.';
return ( return (
<div className="mt-4 space-y-3 border-t border-white/10 pt-3"> <div className="mt-4 space-y-3 border-t border-white/10 pt-3">
@ -1753,12 +2021,35 @@ function ProviderModelList({
</Label> </Label>
</div> </div>
) : null} ) : null}
{hasFreeModels ? (
<div
className="flex h-10 items-center gap-2 rounded-md border border-white/10 px-3"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<Checkbox
id={`runtime-provider-${provider.providerId}-free-only`}
checked={freeOnly}
disabled={disabled || state.modelsLoading}
onCheckedChange={(checked) => setFreeOnly(checked === true)}
className="size-3.5"
/>
<Label
htmlFor={`runtime-provider-${provider.providerId}-free-only`}
className="cursor-pointer text-xs font-normal text-[var(--color-text-secondary)]"
>
Free only
</Label>
</div>
) : null}
</div> </div>
{state.modelsError ? ( {state.modelsError ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200"> <RuntimeProviderErrorAlert
{state.modelsError} message={state.modelsError}
</div> diagnostics={state.modelsErrorDiagnostics}
testId="runtime-provider-models-error"
/>
) : null} ) : null}
<div <div
@ -1768,9 +2059,7 @@ function ProviderModelList({
> >
{!pickerOpen || state.modelsLoading ? <RuntimeProviderModelLoadingSkeleton /> : null} {!pickerOpen || state.modelsLoading ? <RuntimeProviderModelLoadingSkeleton /> : null}
{pickerOpen && !state.modelsLoading && visibleModels.length === 0 && !state.modelsError ? ( {pickerOpen && !state.modelsLoading && visibleModels.length === 0 && !state.modelsError ? (
<div className="text-sm text-[var(--color-text-muted)]"> <div className="text-sm text-[var(--color-text-muted)]">{emptyModelListMessage}</div>
{recommendedOnly ? 'No recommended models found.' : 'No models found.'}
</div>
) : null} ) : null}
{pickerOpen {pickerOpen
? visibleModels.map((model) => ( ? visibleModels.map((model) => (
@ -1843,17 +2132,11 @@ export function RuntimeProviderManagementPanelView({
<RuntimeSummary state={state} disabled={disabled} onRefresh={() => void actions.refresh()} /> <RuntimeSummary state={state} disabled={disabled} onRefresh={() => void actions.refresh()} />
{state.error ? ( {state.error ? (
<div <RuntimeProviderErrorAlert
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs" message={state.error}
style={{ diagnostics={state.errorDiagnostics}
borderColor: 'rgba(248, 113, 113, 0.25)', testId="runtime-provider-error"
backgroundColor: 'rgba(248, 113, 113, 0.06)', />
color: '#fca5a5',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{state.error}</span>
</div>
) : null} ) : null}
{state.successMessage ? ( {state.successMessage ? (
@ -1988,9 +2271,11 @@ export function RuntimeProviderManagementPanelView({
) : null} ) : null}
{state.directoryError ? ( {state.directoryError ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200"> <RuntimeProviderErrorAlert
{state.directoryError} message={state.directoryError}
</div> diagnostics={state.directoryErrorDiagnostics}
testId="runtime-provider-directory-error"
/>
) : null} ) : null}
<div className="max-h-[min(52vh,640px)] space-y-2 overflow-y-auto pr-1"> <div className="max-h-[min(52vh,640px)] space-y-2 overflow-y-auto pr-1">

View file

@ -88,7 +88,9 @@ import {
} from '@main/services/team/TeamMcpConfigBuilder'; } from '@main/services/team/TeamMcpConfigBuilder';
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver'; import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
import { killTrackedCliProcesses } from '@main/utils/childProcess'; import { killTrackedCliProcesses } from '@main/utils/childProcess';
import { getWindowsElevationStatus } from '@main/utils/windowsElevation';
import { import {
APP_GET_WINDOWS_ELEVATION_STATUS,
APP_STARTUP_GET_STATUS, APP_STARTUP_GET_STATUS,
APP_STARTUP_PROGRESS, APP_STARTUP_PROGRESS,
CONTEXT_CHANGED, CONTEXT_CHANGED,
@ -147,6 +149,7 @@ import { clearAutoResumeService } from './services/team/AutoResumeService';
import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer'; import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer';
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor'; import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient'; import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient';
import { OpenCodeBridgeDiagnosticsStore } from './services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore';
import { import {
createOpenCodeBridgeCommandLeaseStore, createOpenCodeBridgeCommandLeaseStore,
createOpenCodeBridgeCommandLedgerStore, createOpenCodeBridgeCommandLedgerStore,
@ -391,6 +394,10 @@ async function createOpenCodeRuntimeAdapterRegistry(
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath(); bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
const useHttpMcpBridge = isOpenCodeMcpHttpBridgeEnabled(bridgeEnv); const useHttpMcpBridge = isOpenCodeMcpHttpBridgeEnabled(bridgeEnv);
const explicitLocalMcpLaunchEnv = snapshotOpenCodeLocalMcpLaunchEnv(bridgeEnv); const explicitLocalMcpLaunchEnv = snapshotOpenCodeLocalMcpLaunchEnv(bridgeEnv);
delete bridgeEnv.ELECTRON_RUN_AS_NODE;
if (explicitLocalMcpLaunchEnv) {
copyOpenCodeLocalMcpLaunchEnv(explicitLocalMcpLaunchEnv, bridgeEnv);
}
delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL; delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
const applyMcpLaunchSpecEnv = async ( const applyMcpLaunchSpecEnv = async (
targetEnv: NodeJS.ProcessEnv, targetEnv: NodeJS.ProcessEnv,
@ -410,6 +417,9 @@ async function createOpenCodeRuntimeAdapterRegistry(
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command; targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry; targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry;
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args); targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args);
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON = JSON.stringify(
mcpLaunchSpec.env ?? {}
);
} }
} catch (error) { } catch (error) {
logger.warn( logger.warn(
@ -515,13 +525,16 @@ async function createOpenCodeRuntimeAdapterRegistry(
} }
return nextEnv; return nextEnv;
}; };
const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
const bridgeClient = new OpenCodeBridgeCommandClient({ const bridgeClient = new OpenCodeBridgeCommandClient({
binaryPath, binaryPath,
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'), tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
env: bridgeEnv, env: bridgeEnv,
envProvider: resolveBridgeCommandEnv, envProvider: resolveBridgeCommandEnv,
diagnostics: new OpenCodeBridgeDiagnosticsStore({
directory: join(bridgeControlDir, 'diagnostics'),
}),
}); });
const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
const clientIdentity = createOpenCodeBridgeClientIdentity({ const clientIdentity = createOpenCodeBridgeClientIdentity({
appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.3.0', appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.3.0',
gitSha: process.env.VITE_GIT_SHA ?? process.env.GIT_SHA ?? null, gitSha: process.env.VITE_GIT_SHA ?? process.env.GIT_SHA ?? null,
@ -546,6 +559,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
}); });
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
stateChangingCommands, stateChangingCommands,
appVersion: clientIdentity.appVersion,
}); });
openCodeLifecycleBridge = readinessBridge; openCodeLifecycleBridge = readinessBridge;
return new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(readinessBridge)]); return new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(readinessBridge)]);
@ -963,6 +977,7 @@ function registerAppStartupHandlers(): void {
appStartupHandlersRegistered = true; appStartupHandlersRegistered = true;
registerRendererLogHandlers(ipcMain); registerRendererLogHandlers(ipcMain);
ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus); ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus);
ipcMain.handle(APP_GET_WINDOWS_ELEVATION_STATUS, () => getWindowsElevationStatus());
} }
function cloneStartupSteps(): AppStartupStep[] { function cloneStartupSteps(): AppStartupStep[] {

View file

@ -97,7 +97,6 @@ import {
import { wrapAgentBlock } from '@shared/constants/agentBlocks'; import { wrapAgentBlock } from '@shared/constants/agentBlocks';
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits'; import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { import {
extractFlagsFromHelp, extractFlagsFromHelp,
extractUserFlags, extractUserFlags,
@ -111,7 +110,6 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { import {
buildStandaloneSlashCommandMeta, buildStandaloneSlashCommandMeta,
parseStandaloneSlashCommand, parseStandaloneSlashCommand,
@ -133,7 +131,6 @@ import {
import { import {
getAutoResumeService, getAutoResumeService,
initializeAutoResumeService, initializeAutoResumeService,
planRateLimitAutoResume,
} from '../services/team/AutoResumeService'; } from '../services/team/AutoResumeService';
import { import {
cloneLaunchIoGovernorPayload, cloneLaunchIoGovernorPayload,
@ -156,6 +153,7 @@ import {
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService'; import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
import { teamMessageNotificationScanner } from './teams/teamMessageNotificationScanner';
import { import {
validateFromField, validateFromField,
validateMemberName, validateMemberName,
@ -301,14 +299,6 @@ function validateTeamGetDataOptions(
}; };
} }
/**
* In-memory set of rate-limit message keys already processed.
* Independent of NotificationManager storage survives notification deletion/pruning.
* Without this, deleted rate-limit notifications would re-appear on next getData() scan.
*/
const seenRateLimitKeys = new Set<string>();
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
async function withTimeoutValue<T>( async function withTimeoutValue<T>(
promise: Promise<T>, promise: Promise<T>,
timeoutMs: number, timeoutMs: number,
@ -442,178 +432,6 @@ function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string |
); );
} }
/**
* In-memory set of API error message keys already processed.
* Independent of NotificationManager storage survives notification deletion/pruning.
*/
const seenApiErrorKeys = new Set<string>();
const SEEN_API_ERROR_KEYS_MAX = 500;
function formatNotificationClockTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function buildRateLimitNotificationBody(plan: ReturnType<typeof planRateLimitAutoResume>): string {
if (plan.kind === 'scheduled') {
return `Auto-resume scheduled at ${formatNotificationClockTime(new Date(plan.fireAtMs))}`;
}
return 'Manual restart needed';
}
/**
* Check messages for rate limit indicators and fire notifications for new ones.
* Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion)
* and NotificationManager dedupeKey (to prevent storage duplicates).
*/
function checkRateLimitMessages(
messages: readonly {
messageId?: string;
from: string;
text: string;
timestamp: string;
to?: string;
source?: string;
leadSessionId?: string;
}[],
teamName: string,
teamDisplayName: string,
projectPath?: string,
teamIsAlive = true,
currentLeadSessionId: string | null = null
): void {
const observedAt = new Date();
const autoResumeEnabled =
ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit;
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!isRateLimitMessage(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
const isLeadAutoResumeCandidate =
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
const autoResumeSessionMatches =
msg.source !== 'lead_session' ||
(Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId);
const autoResumePlan = planRateLimitAutoResume({
enabled: autoResumeEnabled,
canAutoResume: teamIsAlive && isLeadAutoResumeCandidate && autoResumeSessionMatches,
messageText: msg.text,
observedAt,
messageTimestamp: new Date(msg.timestamp),
});
// In-memory guard: prevents resurrection after user deletes the notification.
if (!seenRateLimitKeys.has(dedupeKey)) {
seenRateLimitKeys.add(dedupeKey);
// Evict oldest entries to prevent unbounded growth
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
const first = seenRateLimitKeys.values().next().value;
if (first) seenRateLimitKeys.delete(first);
}
void NotificationManager.getInstance()
.addTeamNotification({
teamEventType: 'rate_limit',
teamName,
teamDisplayName,
from: msg.from,
summary: 'Rate limit',
body: buildRateLimitNotificationBody(autoResumePlan),
dedupeKey,
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
projectPath,
})
.catch(() => undefined);
}
// Only schedule auto-resume while a live team run currently exists.
// Persisted history for an offline/stopped team may still contain the old
// rate-limit message, but arming a new timer from that stale history would
// resurrect the nudge into a later manual restart.
if (autoResumePlan.kind === 'scheduled') {
// Only let persisted lead_session history rebuild auto-resume when it
// clearly belongs to the currently running lead session. Otherwise an old
// rate-limit from a previous manual run can resurrect into a newer restart.
// Pass the original message timestamp so relative reset windows survive restarts
// and old history does not rebuild a fresh auto-resume timer from "now".
getAutoResumeService().handleRateLimitMessage(
teamName,
msg.text,
observedAt,
new Date(msg.timestamp)
);
}
}
}
/**
* Check messages for API errors (e.g. "API Error: 429 ...") and fire OS notifications.
* Mirrors the rate-limit approach: in-memory dedup + NotificationManager dedupeKey.
* Skips rate-limit messages (they have their own notification path).
*/
function checkApiErrorMessages(
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
teamName: string,
teamDisplayName: string,
projectPath?: string
): void {
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!isApiErrorMessage(msg.text)) continue;
// Don't double-notify if it's also a rate limit message
if (isRateLimitMessage(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `api-error:${teamName}:${rawKey}`;
if (seenApiErrorKeys.has(dedupeKey)) continue;
seenApiErrorKeys.add(dedupeKey);
if (seenApiErrorKeys.size > SEEN_API_ERROR_KEYS_MAX) {
const first = seenApiErrorKeys.values().next().value;
if (first) seenApiErrorKeys.delete(first);
}
// Extract status code for summary
const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text);
const statusCode = statusMatch?.[1] ?? '???';
void NotificationManager.getInstance()
.addTeamNotification({
teamEventType: 'api_error',
teamName,
teamDisplayName,
from: msg.from,
summary: `API Error ${statusCode}`,
body: 'Manual restart needed',
dedupeKey,
target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
projectPath,
})
.catch(() => undefined);
}
}
function scanTeamMessageNotifications(
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
teamName: string,
teamDisplayName: string,
projectPath?: string
): void {
if (messages.length === 0) {
return;
}
checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath);
checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath);
}
let teamDataService: TeamDataService | null = null; let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null; let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
@ -1145,17 +963,24 @@ async function handleGetData(
if (live.length === 0) { if (live.length === 0) {
if (durableMessages.length > 0) { if (durableMessages.length > 0) {
checkRateLimitMessages( teamMessageNotificationScanner.checkRateLimitMessages(durableMessages, {
durableMessages, teamName: tn,
tn, teamDisplayName: displayName,
displayName,
projectPath, projectPath,
isAlive, teamIsAlive: isAlive,
currentLeadSessionId currentLeadSessionId,
); });
checkApiErrorMessages(durableMessages, tn, displayName, projectPath); teamMessageNotificationScanner.checkApiErrorMessages(durableMessages, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
} else { } else {
scanTeamMessageNotifications(live, tn, displayName, projectPath); teamMessageNotificationScanner.scan(live, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
} }
return { success: true, data: { ...data, isAlive } }; return { success: true, data: { ...data, isAlive } };
} }
@ -1177,8 +1002,18 @@ async function handleGetData(
} }
} }
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId); teamMessageNotificationScanner.checkRateLimitMessages(merged, {
checkApiErrorMessages(merged, tn, displayName, projectPath); teamName: tn,
teamDisplayName: displayName,
projectPath,
teamIsAlive: isAlive,
currentLeadSessionId,
});
teamMessageNotificationScanner.checkApiErrorMessages(merged, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
return { success: true, data: { ...data, isAlive } }; return { success: true, data: { ...data, isAlive } };
} }
@ -2844,12 +2679,11 @@ async function handleGetMessagesPage(
.catch(() => ({ displayName: teamName })); .catch(() => ({ displayName: teamName }));
void notificationContextPromise void notificationContextPromise
.then((notificationContext) => { .then((notificationContext) => {
scanTeamMessageNotifications( teamMessageNotificationScanner.scan(messagesPage.messages, {
messagesPage.messages,
teamName, teamName,
notificationContext.displayName, teamDisplayName: notificationContext.displayName,
notificationContext.projectPath projectPath: notificationContext.projectPath,
); });
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
logger.debug( logger.debug(

View file

@ -0,0 +1,240 @@
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
import {
getAutoResumeService,
planRateLimitAutoResume,
type RateLimitAutoResumePlan,
} from '@main/services/team/AutoResumeService';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import type { TeamNotificationPayload } from '@main/utils/teamNotificationBuilder';
export interface TeamNotificationMessage {
messageId?: string;
from: string;
text: string;
timestamp: string;
to?: string;
source?: string;
leadSessionId?: string;
}
interface TeamNotificationSink {
addTeamNotification(payload: TeamNotificationPayload): Promise<unknown>;
}
interface AutoResumeSink {
handleRateLimitMessage(
teamName: string,
messageText: string,
observedAt: Date,
messageTimestamp: Date
): void;
}
interface ConfigReader {
getConfig(): {
notifications: {
autoResumeOnRateLimit: boolean;
};
};
}
export interface TeamMessageNotificationScannerDeps {
configReader?: ConfigReader;
notificationSink?: TeamNotificationSink;
autoResumeSink?: AutoResumeSink;
planAutoResume?: typeof planRateLimitAutoResume;
isRateLimit?: (text: string) => boolean;
isApiError?: (text: string) => boolean;
now?: () => Date;
formatClockTime?: (date: Date) => string;
}
export interface TeamMessageNotificationContext {
teamName: string;
teamDisplayName: string;
projectPath?: string;
teamIsAlive?: boolean;
currentLeadSessionId?: string | null;
}
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
const SEEN_API_ERROR_KEYS_MAX = 500;
function formatNotificationClockTime(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function buildRateLimitNotificationBody(
plan: RateLimitAutoResumePlan,
formatClockTime: (date: Date) => string
): string {
if (plan.kind === 'scheduled') {
return `Auto-resume scheduled at ${formatClockTime(new Date(plan.fireAtMs))}`;
}
return 'Manual restart needed';
}
function evictOldestIfNeeded(keys: Set<string>, maxSize: number): void {
if (keys.size <= maxSize) {
return;
}
const first = keys.values().next().value;
if (first) {
keys.delete(first);
}
}
function createDefaultNotificationSink(): TeamNotificationSink {
return {
addTeamNotification: (payload) => NotificationManager.getInstance().addTeamNotification(payload),
};
}
export class TeamMessageNotificationScanner {
readonly #seenRateLimitKeys = new Set<string>();
readonly #seenApiErrorKeys = new Set<string>();
readonly #configReader: ConfigReader;
readonly #notificationSink: TeamNotificationSink;
readonly #planAutoResume: typeof planRateLimitAutoResume;
readonly #isRateLimit: (text: string) => boolean;
readonly #isApiError: (text: string) => boolean;
readonly #now: () => Date;
readonly #formatClockTime: (date: Date) => string;
readonly #autoResumeSink: AutoResumeSink | null;
constructor(deps: TeamMessageNotificationScannerDeps = {}) {
this.#configReader = deps.configReader ?? ConfigManager.getInstance();
this.#notificationSink = deps.notificationSink ?? createDefaultNotificationSink();
this.#planAutoResume = deps.planAutoResume ?? planRateLimitAutoResume;
this.#isRateLimit = deps.isRateLimit ?? isRateLimitMessage;
this.#isApiError = deps.isApiError ?? isApiErrorMessage;
this.#now = deps.now ?? (() => new Date());
this.#formatClockTime = deps.formatClockTime ?? formatNotificationClockTime;
this.#autoResumeSink = deps.autoResumeSink ?? null;
}
checkRateLimitMessages(
messages: readonly TeamNotificationMessage[],
context: TeamMessageNotificationContext
): void {
const observedAt = this.#now();
const autoResumeEnabled = this.#configReader.getConfig().notifications.autoResumeOnRateLimit;
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!this.#isRateLimit(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `rate-limit:${context.teamName}:${rawKey}`;
const isLeadAutoResumeCandidate =
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
const currentLeadSessionId = context.currentLeadSessionId ?? null;
const autoResumeSessionMatches =
msg.source !== 'lead_session' ||
(Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId);
const autoResumePlan = this.#planAutoResume({
enabled: autoResumeEnabled,
canAutoResume:
(context.teamIsAlive ?? true) &&
isLeadAutoResumeCandidate &&
autoResumeSessionMatches,
messageText: msg.text,
observedAt,
messageTimestamp: new Date(msg.timestamp),
});
if (!this.#seenRateLimitKeys.has(dedupeKey)) {
this.#seenRateLimitKeys.add(dedupeKey);
evictOldestIfNeeded(this.#seenRateLimitKeys, SEEN_RATE_LIMIT_KEYS_MAX);
void this.#notificationSink
.addTeamNotification({
teamEventType: 'rate_limit',
teamName: context.teamName,
teamDisplayName: context.teamDisplayName,
from: msg.from,
summary: 'Rate limit',
body: buildRateLimitNotificationBody(autoResumePlan, this.#formatClockTime),
dedupeKey,
target: {
kind: 'member',
teamName: context.teamName,
memberName: msg.from,
focus: 'logs',
},
projectPath: context.projectPath,
})
.catch(() => undefined);
}
if (autoResumePlan.kind === 'scheduled') {
const autoResumeSink = this.#autoResumeSink ?? getAutoResumeService();
autoResumeSink.handleRateLimitMessage(
context.teamName,
msg.text,
observedAt,
new Date(msg.timestamp)
);
}
}
}
checkApiErrorMessages(
messages: readonly TeamNotificationMessage[],
context: TeamMessageNotificationContext
): void {
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!this.#isApiError(msg.text)) continue;
if (this.#isRateLimit(msg.text)) continue;
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const dedupeKey = `api-error:${context.teamName}:${rawKey}`;
if (this.#seenApiErrorKeys.has(dedupeKey)) continue;
this.#seenApiErrorKeys.add(dedupeKey);
evictOldestIfNeeded(this.#seenApiErrorKeys, SEEN_API_ERROR_KEYS_MAX);
const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text);
const statusCode = statusMatch?.[1] ?? '???';
void this.#notificationSink
.addTeamNotification({
teamEventType: 'api_error',
teamName: context.teamName,
teamDisplayName: context.teamDisplayName,
from: msg.from,
summary: `API Error ${statusCode}`,
body: 'Manual restart needed',
dedupeKey,
target: {
kind: 'member',
teamName: context.teamName,
memberName: msg.from,
focus: 'logs',
},
projectPath: context.projectPath,
})
.catch(() => undefined);
}
}
scan(messages: readonly TeamNotificationMessage[], context: TeamMessageNotificationContext): void {
if (messages.length === 0) {
return;
}
this.checkRateLimitMessages(messages, context);
this.checkApiErrorMessages(messages, context);
}
}
export const teamMessageNotificationScanner = new TeamMessageNotificationScanner();

View file

@ -435,6 +435,10 @@ export class FileWatcher extends EventEmitter {
// Guard: stop() may have been called while awaiting pathExists // Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return; if (!this.isWatching) return;
// Teams deliberately use TeamTaskWatchRegistry instead of recursive fs.watch.
// Linux recursive watching expands across the whole team runtime tree and can
// hit EMFILE/ENOSPC. The registry keeps the watched surface aligned with
// processTeamsChange(): team root JSON files plus inbox JSON files only.
const registry = new TeamTaskWatchRegistry({ const registry = new TeamTaskWatchRegistry({
kind: 'teams', kind: 'teams',
rootPath: this.teamsPath, rootPath: this.teamsPath,
@ -479,6 +483,8 @@ export class FileWatcher extends EventEmitter {
// Guard: stop() may have been called while awaiting pathExists // Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return; if (!this.isWatching) return;
// Tasks share the same shallow registry rule as teams. Keep polling out of
// the normal path here; it is only the known-error fallback below.
const registry = new TeamTaskWatchRegistry({ const registry = new TeamTaskWatchRegistry({
kind: 'tasks', kind: 'tasks',
rootPath: this.tasksPath, rootPath: this.tasksPath,
@ -647,6 +653,9 @@ export class FileWatcher extends EventEmitter {
error: unknown, error: unknown,
watcher?: CloseableWatcher watcher?: CloseableWatcher
): boolean { ): boolean {
// Polling fallback is intentionally narrow. Projects/todos keep their native
// watcher retry behavior, while teams/tasks can switch to scoped polling only
// after known OS watcher-limit or platform errors from Chokidar/fs.watch.
if ((watcherType !== 'teams' && watcherType !== 'tasks') || !this.isWatchLimitError(error)) { if ((watcherType !== 'teams' && watcherType !== 'tasks') || !this.isWatchLimitError(error)) {
return false; return false;
} }
@ -721,6 +730,8 @@ export class FileWatcher extends EventEmitter {
}; };
runPoll(); runPoll();
// This is fallback content polling after watcher failure, not the default mode.
// Keep intervals conservative and scoped to the same shallow artifacts as the registry.
const timer = setInterval(runPoll, this.getTeamTaskPollIntervalMs(watcherType)); const timer = setInterval(runPoll, this.getTeamTaskPollIntervalMs(watcherType));
timer.unref(); timer.unref();
@ -799,6 +810,8 @@ export class FileWatcher extends EventEmitter {
const snapshot = new Map<string, string>(); const snapshot = new Map<string, string>();
const teamEntries = await this.safeReadDir(this.teamsPath); const teamEntries = await this.safeReadDir(this.teamsPath);
// Fallback polling mirrors TeamTaskWatchRegistry. Do not recurse into members,
// runtime, .opencode-runtime, logs, or other deep trees from here.
for (const teamEntry of teamEntries) { for (const teamEntry of teamEntries) {
if (!teamEntry.isDirectory()) { if (!teamEntry.isDirectory()) {
continue; continue;
@ -825,6 +838,8 @@ export class FileWatcher extends EventEmitter {
const snapshot = new Map<string, string>(); const snapshot = new Map<string, string>();
const teamEntries = await this.safeReadDir(this.tasksPath); const teamEntries = await this.safeReadDir(this.tasksPath);
// Keep task fallback scoped to tasks/<team>/*.json. Hidden files and nested
// runtime directories are intentionally outside the public team-change surface.
for (const teamEntry of teamEntries) { for (const teamEntry of teamEntries) {
if (!teamEntry.isDirectory()) { if (!teamEntry.isDirectory()) {
continue; continue;
@ -1351,6 +1366,9 @@ export class FileWatcher extends EventEmitter {
return; return;
} }
// Keep this classifier in lockstep with TeamTaskWatchRegistry.shouldEmit().
// If a path is emitted by the registry but ignored here, the UI will miss it.
// If a path is added here but not emitted there, Chokidar mode will never see it.
if (relative === 'processes.json') { if (relative === 'processes.json') {
const event: TeamChangeEvent = { type: 'process', teamName, detail: relative }; const event: TeamChangeEvent = { type: 'process', teamName, detail: relative };
this.emit('team-change', event); this.emit('team-change', event);
@ -1414,6 +1432,8 @@ export class FileWatcher extends EventEmitter {
return; return;
} }
// Keep this in sync with the tasks registry and fallback polling filters:
// only tasks/<team>/*.json is a user-visible task event.
// Ignore known non-task files in ~/.claude/tasks // Ignore known non-task files in ~/.claude/tasks
if ( if (
relative === '.lock' || relative === '.lock' ||

View file

@ -23,8 +23,8 @@ import type {
const logger = createLogger('ClaudeMultimodelBridgeService'); const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 25_000; const PROVIDER_STATUS_TIMEOUT_MS = 90_000;
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 15_000; const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 30_000;
const PROVIDER_MODELS_TIMEOUT_MS = 25_000; const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024; const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024; const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
@ -396,11 +396,21 @@ function createRuntimeStatusErrorProviderStatus(
error: unknown error: unknown
): CliProviderStatus { ): CliProviderStatus {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
const detailMessage =
providerId === 'opencode' && (lower.includes('timed out') || lower.includes('timeout'))
? [
'OpenCode runtime status did not return before the desktop timeout.',
'This means the Agent Teams runtime process did not produce provider-status JSON in time, not necessarily that OpenCode auth is missing.',
'Likely causes include slow or hung OpenCode CLI startup, provider/model inventory, local OpenCode plugins, cache/profile corruption, stale bundled runtime, or Windows security software delaying child processes.',
`Raw timeout detail: ${message}`,
].join(' ')
: message;
return { return {
...createDefaultProviderStatus(providerId), ...createDefaultProviderStatus(providerId),
verificationState: 'error', verificationState: 'error',
statusMessage: 'Provider status unavailable', statusMessage: 'Provider status unavailable',
detailMessage: message, detailMessage,
}; };
} }
@ -985,8 +995,11 @@ export class ClaudeMultimodelBridgeService {
if (options.summary) { if (options.summary) {
args.push('--summary'); args.push('--summary');
} }
const timeout =
options.timeoutMs ??
(options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS);
const { stdout } = await execCli(binaryPath, args, { const { stdout } = await execCli(binaryPath, args, {
timeout: options.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS, timeout,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES, maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
env, env,
}); });

View file

@ -8,6 +8,8 @@ const logger = createLogger('Runtime:AgentTeamsMcpLaunchEnv');
const MCP_COMMAND_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND'; const MCP_COMMAND_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND';
const MCP_ENTRY_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY'; const MCP_ENTRY_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY';
const MCP_ARGS_JSON_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON'; const MCP_ARGS_JSON_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON';
const MCP_ENV_JSON_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON';
const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
export type AgentTeamsMcpLaunchEnv = Record<string, string | undefined>; export type AgentTeamsMcpLaunchEnv = Record<string, string | undefined>;
@ -17,11 +19,24 @@ export function hasAgentTeamsMcpLocalLaunchEnv(env: AgentTeamsMcpLaunchEnv): boo
); );
} }
function ensureLegacyMcpChildEnvJson(env: AgentTeamsMcpLaunchEnv): void {
if (env[MCP_ENV_JSON_ENV]?.trim()) {
return;
}
const electronRunAsNode = env[ELECTRON_RUN_AS_NODE_ENV]?.trim();
if (electronRunAsNode) {
env[MCP_ENV_JSON_ENV] = JSON.stringify({
[ELECTRON_RUN_AS_NODE_ENV]: electronRunAsNode,
});
}
}
export async function ensureAgentTeamsMcpLocalLaunchEnv( export async function ensureAgentTeamsMcpLocalLaunchEnv(
env: AgentTeamsMcpLaunchEnv, env: AgentTeamsMcpLaunchEnv,
resolveLaunchSpec: () => Promise<McpLaunchSpec> = resolveAgentTeamsMcpLaunchSpec resolveLaunchSpec: () => Promise<McpLaunchSpec> = resolveAgentTeamsMcpLaunchSpec
): Promise<void> { ): Promise<void> {
if (hasAgentTeamsMcpLocalLaunchEnv(env)) { if (hasAgentTeamsMcpLocalLaunchEnv(env)) {
ensureLegacyMcpChildEnvJson(env);
return; return;
} }
@ -36,6 +51,7 @@ export async function ensureAgentTeamsMcpLocalLaunchEnv(
env[MCP_COMMAND_ENV] = command; env[MCP_COMMAND_ENV] = command;
env[MCP_ENTRY_ENV] = entry; env[MCP_ENTRY_ENV] = entry;
env[MCP_ARGS_JSON_ENV] = JSON.stringify(launchSpec.args); env[MCP_ARGS_JSON_ENV] = JSON.stringify(launchSpec.args);
env[MCP_ENV_JSON_ENV] = JSON.stringify(launchSpec.env ?? {});
} catch (error) { } catch (error) {
logger.warn( logger.warn(
`Unable to resolve Agent Teams MCP local launch env: ${ `Unable to resolve Agent Teams MCP local launch env: ${

View file

@ -11,6 +11,7 @@ import { providerConnectionService } from './ProviderConnectionService';
import type { CliProviderId, TeamProviderId } from '@shared/types'; import type { CliProviderId, TeamProviderId } from '@shared/types';
type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined; type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined;
const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
export interface ProviderAwareCliEnvOptions { export interface ProviderAwareCliEnvOptions {
binaryPath?: string | null; binaryPath?: string | null;
@ -29,6 +30,10 @@ export interface ProviderAwareCliEnvResult {
providerArgs: string[]; providerArgs: string[];
} }
function removeGlobalElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): void {
delete env[ELECTRON_RUN_AS_NODE_ENV];
}
export async function buildProviderAwareCliEnv( export async function buildProviderAwareCliEnv(
options: ProviderAwareCliEnvOptions = {} options: ProviderAwareCliEnvOptions = {}
): Promise<ProviderAwareCliEnvResult> { ): Promise<ProviderAwareCliEnvResult> {
@ -79,6 +84,7 @@ export async function buildProviderAwareCliEnv(
options.providerBackendId, options.providerBackendId,
...storedApiKeyAccessArgs ...storedApiKeyAccessArgs
); );
removeGlobalElectronRunAsNodeEnv(env);
return { return {
env, env,
connectionIssues: {}, connectionIssues: {},
@ -92,22 +98,25 @@ export async function buildProviderAwareCliEnv(
options.providerBackendId, options.providerBackendId,
...storedApiKeyAccessArgs ...storedApiKeyAccessArgs
); );
removeGlobalElectronRunAsNodeEnv(env);
const providerArgs = await providerConnectionService.getConfiguredConnectionLaunchArgs(
env,
resolvedProviderId,
options.providerBackendId,
options.binaryPath
);
const connectionIssues = await providerConnectionService.getConfiguredConnectionIssues(
env,
[resolvedProviderId],
resolvedProviderId === 'codex' || resolvedProviderId === 'gemini'
? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined }
: undefined
);
return { return {
env, env,
providerArgs: await providerConnectionService.getConfiguredConnectionLaunchArgs( providerArgs,
env, connectionIssues,
resolvedProviderId,
options.providerBackendId,
options.binaryPath
),
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(
env,
[resolvedProviderId],
resolvedProviderId === 'codex' || resolvedProviderId === 'gemini'
? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined }
: undefined
),
}; };
} }
@ -116,6 +125,7 @@ export async function buildProviderAwareCliEnv(
env, env,
...storedApiKeyAccessArgs ...storedApiKeyAccessArgs
); );
removeGlobalElectronRunAsNodeEnv(env);
return { return {
env, env,
connectionIssues: {}, connectionIssues: {},
@ -124,9 +134,11 @@ export async function buildProviderAwareCliEnv(
} }
await providerConnectionService.applyAllConfiguredConnectionEnv(env, ...storedApiKeyAccessArgs); await providerConnectionService.applyAllConfiguredConnectionEnv(env, ...storedApiKeyAccessArgs);
removeGlobalElectronRunAsNodeEnv(env);
const connectionIssues = await providerConnectionService.getConfiguredConnectionIssues(env);
return { return {
env, env,
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env), connectionIssues,
providerArgs: [], providerArgs: [],
}; };
} }

View file

@ -405,7 +405,12 @@ function buildStatePath(): string {
} }
function buildLaunchSpecHash(launchSpec: McpLaunchSpec): string { function buildLaunchSpecHash(launchSpec: McpLaunchSpec): string {
return sha256Hex(JSON.stringify({ command: launchSpec.command, args: launchSpec.args })); const env = launchSpec.env
? Object.fromEntries(
Object.entries(launchSpec.env).sort(([left], [right]) => left.localeCompare(right))
)
: {};
return sha256Hex(JSON.stringify({ command: launchSpec.command, args: launchSpec.args, env }));
} }
function buildExpectedIdentity( function buildExpectedIdentity(
@ -1095,6 +1100,7 @@ export class AgentTeamsMcpHttpServer {
}; };
const childEnv = applyAgentTeamsIdentityEnv({ const childEnv = applyAgentTeamsIdentityEnv({
...process.env, ...process.env,
...launchSpec.env,
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(), AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST, AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST,

View file

@ -105,7 +105,7 @@ const logger = createLogger('Service:TeamDataService');
const MIN_TEXT_LENGTH = 30; const MIN_TEXT_LENGTH = 30;
const MAX_LEAD_TEXTS = 150; const MAX_LEAD_TEXTS = 150;
const LEAD_SESSION_PARSE_CACHE_SCHEMA_VERSION = 'combined-v1'; const LEAD_SESSION_PARSE_CACHE_SCHEMA_VERSION = 'combined-v2';
const PROCESS_HEALTH_INTERVAL_MS = 2_000; const PROCESS_HEALTH_INTERVAL_MS = 2_000;
const TASK_MAP_YIELD_EVERY = 250; const TASK_MAP_YIELD_EVERY = 250;
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification'; const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
@ -3221,7 +3221,8 @@ export class TeamDataService {
const MAX_SCAN_BYTES = 8 * 1024 * 1024; const MAX_SCAN_BYTES = 8 * 1024 * 1024;
const INITIAL_SCAN_BYTES = 256 * 1024; const INITIAL_SCAN_BYTES = 256 * 1024;
const textsReversed: InboxMessage[] = []; const rawLinesReversed: string[] = [];
const seenRawLines = new Set<string>();
const seenMessageIds = new Set<string>(); const seenMessageIds = new Set<string>();
const handle = await fs.promises.open(jsonlPath, 'r'); const handle = await fs.promises.open(jsonlPath, 'r');
try { try {
@ -3229,7 +3230,7 @@ export class TeamDataService {
const fileSize = stat.size; const fileSize = stat.size;
let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize); let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize);
while (textsReversed.length < maxTexts && scanBytes <= MAX_SCAN_BYTES) { while (scanBytes <= MAX_SCAN_BYTES) {
const start = Math.max(0, fileSize - scanBytes); const start = Math.max(0, fileSize - scanBytes);
const buffer = Buffer.alloc(scanBytes); const buffer = Buffer.alloc(scanBytes);
await handle.read(buffer, 0, scanBytes, start); await handle.read(buffer, 0, scanBytes, start);
@ -3241,96 +3242,11 @@ export class TeamDataService {
for (let i = lines.length - 1; i >= fromIndex; i--) { for (let i = lines.length - 1; i >= fromIndex; i--) {
const trimmed = lines[i]?.trim(); const trimmed = lines[i]?.trim();
if (!trimmed) continue; if (!trimmed) continue;
if (seenRawLines.has(trimmed)) continue;
let msg: Record<string, unknown>; seenRawLines.add(trimmed);
try { rawLinesReversed.push(trimmed);
msg = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
}
if (msg.type !== 'assistant') continue;
const message = (msg.message ?? msg) as Record<string, unknown>;
const content = message.content;
if (!Array.isArray(content)) continue;
const timestamp =
typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
const textParts: string[] = [];
for (const block of content as Record<string, unknown>[]) {
if (block.type !== 'text' || typeof block.text !== 'string') continue;
textParts.push(block.text);
}
if (textParts.length === 0) continue;
const combined = stripAgentBlocks(textParts.join('\n')).trim();
if (combined.length < MIN_TEXT_LENGTH) continue;
const toolCallsList: ToolCallMeta[] = [];
const lookaheadLimit = Math.min(i + 200, lines.length);
for (let j = i + 1; j < lookaheadLimit; j++) {
const tLine = lines[j]?.trim();
if (!tLine) continue;
let tMsg: Record<string, unknown>;
try {
tMsg = JSON.parse(tLine) as Record<string, unknown>;
} catch {
continue;
}
if (tMsg.type !== 'assistant') continue;
const tMessage = (tMsg.message ?? tMsg) as Record<string, unknown>;
const tContent = tMessage.content;
if (!Array.isArray(tContent)) continue;
const tBlocks = tContent as Record<string, unknown>[];
if (tBlocks.some((b) => b.type === 'text')) break;
for (const b of tBlocks) {
if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
const input = (b.input ?? {}) as Record<string, unknown>;
toolCallsList.push({
name: b.name,
preview: extractToolPreview(b.name, input),
});
}
}
}
const toolCalls = toolCallsList.length > 0 ? toolCallsList : undefined;
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
const entryUuid = typeof msg.uuid === 'string' ? msg.uuid.trim() : '';
const assistantMessageId = typeof message.id === 'string' ? message.id.trim() : '';
const stableMessageId = entryUuid
? `lead-thought-${entryUuid}`
: assistantMessageId
? `lead-thought-msg-${assistantMessageId}`
: null;
const textPrefix = combined
.slice(0, 50)
.replace(/[^\p{L}\p{N}]/gu, '')
.slice(0, 20);
const messageId =
stableMessageId ?? `lead-session-${leadSessionId}-${timestamp}-${textPrefix}`;
if (seenMessageIds.has(messageId)) continue;
seenMessageIds.add(messageId);
textsReversed.push({
from: leadName,
text: combined,
timestamp,
read: true,
source: 'lead_session',
leadSessionId,
messageId,
toolSummary,
toolCalls,
});
if (textsReversed.length >= maxTexts) break;
} }
if (textsReversed.length >= maxTexts) break;
if (scanBytes === fileSize) break; if (scanBytes === fileSize) break;
scanBytes = Math.min(fileSize, scanBytes * 2); scanBytes = Math.min(fileSize, scanBytes * 2);
} }
@ -3338,8 +3254,163 @@ export class TeamDataService {
await handle.close(); await handle.close();
} }
textsReversed.reverse(); const rawLines = rawLinesReversed.reverse();
return textsReversed.length > maxTexts ? textsReversed.slice(-maxTexts) : textsReversed; const texts: InboxMessage[] = [];
let syntheticBuffer: {
firstMsg: Record<string, unknown>;
firstMessage: Record<string, unknown>;
timestamp: string;
parts: string[];
} | null = null;
const collectToolCallsAfterIndex = (index: number): ToolCallMeta[] | undefined => {
const toolCallsList: ToolCallMeta[] = [];
const lookaheadLimit = Math.min(index + 200, rawLines.length);
for (let j = index + 1; j < lookaheadLimit; j++) {
const tLine = rawLines[j]?.trim();
if (!tLine) continue;
let tMsg: Record<string, unknown>;
try {
tMsg = JSON.parse(tLine) as Record<string, unknown>;
} catch {
continue;
}
if (tMsg.type !== 'assistant') break;
const tMessage = (tMsg.message ?? tMsg) as Record<string, unknown>;
const tContent = tMessage.content;
if (!Array.isArray(tContent)) continue;
const tBlocks = tContent as Record<string, unknown>[];
if (tBlocks.some((b) => b.type === 'text')) break;
for (const b of tBlocks) {
if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
const input = (b.input ?? {}) as Record<string, unknown>;
toolCallsList.push({
name: b.name,
preview: extractToolPreview(b.name, input),
});
}
}
}
return toolCallsList.length > 0 ? toolCallsList : undefined;
};
const pushLeadText = (
msg: Record<string, unknown>,
message: Record<string, unknown>,
combined: string,
timestamp: string,
toolCalls?: ToolCallMeta[],
streamGroup = false
): void => {
if (combined.length < MIN_TEXT_LENGTH) return;
const entryUuid = typeof msg.uuid === 'string' ? msg.uuid.trim() : '';
const assistantMessageId = typeof message.id === 'string' ? message.id.trim() : '';
const stableMessageId = entryUuid
? streamGroup
? `lead-thought-stream-${entryUuid}`
: `lead-thought-${entryUuid}`
: assistantMessageId
? `lead-thought-msg-${assistantMessageId}`
: null;
const textPrefix = combined
.slice(0, 50)
.replace(/[^\p{L}\p{N}]/gu, '')
.slice(0, 20);
const messageId =
stableMessageId ?? `lead-session-${leadSessionId}-${timestamp}-${textPrefix}`;
if (seenMessageIds.has(messageId)) return;
seenMessageIds.add(messageId);
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
texts.push({
from: leadName,
text: combined,
timestamp,
read: true,
source: 'lead_session',
leadSessionId,
messageId,
toolSummary,
toolCalls,
});
};
const flushSyntheticBuffer = (): void => {
if (!syntheticBuffer) return;
const combined = stripAgentBlocks(syntheticBuffer.parts.join('')).trim();
pushLeadText(
syntheticBuffer.firstMsg,
syntheticBuffer.firstMessage,
combined,
syntheticBuffer.timestamp,
undefined,
true
);
syntheticBuffer = null;
};
for (let i = 0; i < rawLines.length; i++) {
const trimmed = rawLines[i]?.trim();
if (!trimmed) continue;
let msg: Record<string, unknown>;
try {
msg = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
}
if (msg.type !== 'assistant') {
flushSyntheticBuffer();
continue;
}
const message = (msg.message ?? msg) as Record<string, unknown>;
const content = message.content;
if (!Array.isArray(content)) {
flushSyntheticBuffer();
continue;
}
const textParts: string[] = [];
for (const block of content as Record<string, unknown>[]) {
if (block.type !== 'text' || typeof block.text !== 'string') continue;
textParts.push(block.text);
}
if (textParts.length === 0) {
if ((content as Record<string, unknown>[]).some((block) => block.type === 'tool_use')) {
flushSyntheticBuffer();
}
continue;
}
const timestamp =
typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
const isSyntheticChunk = message.model === '<synthetic>' && message.type === 'message';
if (isSyntheticChunk) {
if (!syntheticBuffer) {
syntheticBuffer = {
firstMsg: msg,
firstMessage: message,
timestamp,
parts: [],
};
}
syntheticBuffer.parts.push(textParts.join(''));
continue;
}
flushSyntheticBuffer();
const combined = stripAgentBlocks(textParts.join('\n')).trim();
pushLeadText(msg, message, combined, timestamp, collectToolCallsAfterIndex(i));
}
flushSyntheticBuffer();
return texts.length > maxTexts ? texts.slice(-maxTexts) : texts;
} }
private async extractLeadSessionTextsFromJsonl( private async extractLeadSessionTextsFromJsonl(

View file

@ -21,6 +21,7 @@ import type { TeamMemberMcpPolicy, TeamMemberMcpScope } from '@shared/types';
export interface McpLaunchSpec { export interface McpLaunchSpec {
command: string; command: string;
args: string[]; args: string[];
env?: Record<string, string>;
} }
export interface McpLaunchSpecResolveProgress { export interface McpLaunchSpecResolveProgress {
@ -40,10 +41,12 @@ interface WriteMcpConfigOptions {
const MCP_SERVER_NAME = 'agent-teams'; const MCP_SERVER_NAME = 'agent-teams';
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
const MCP_CONTROL_URL_ENV = 'CLAUDE_TEAM_CONTROL_URL'; const MCP_CONTROL_URL_ENV = 'CLAUDE_TEAM_CONTROL_URL';
const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
const logger = createLogger('Service:TeamMcpConfigBuilder'); const logger = createLogger('Service:TeamMcpConfigBuilder');
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const; const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000; const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
const ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
/** /**
* Stale configs older than this are removed on startup (best-effort). * Stale configs older than this are removed on startup (best-effort).
* 7 days is intentionally long: respawnAfterAuthFailure() reuses saved * 7 days is intentionally long: respawnAfterAuthFailure() reuses saved
@ -58,6 +61,7 @@ const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'pro
function isPackagedApp(): boolean { function isPackagedApp(): boolean {
try { try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { app } = require('electron') as typeof import('electron'); const { app } = require('electron') as typeof import('electron');
return app.isPackaged; return app.isPackaged;
} catch { } catch {
@ -88,6 +92,26 @@ function getWorkspaceRoot(): string {
return process.cwd(); return process.cwd();
} }
function shouldUsePackagedElectronNodeRuntime(): boolean {
return (
isPackagedApp() && typeof process.execPath === 'string' && process.execPath.trim().length > 0
);
}
function getPackagedElectronNodeEnv(): Record<string, string> {
return {
[ELECTRON_RUN_AS_NODE_ENV]: '1',
};
}
function buildPackagedElectronNodeLaunchSpec(entry: string): McpLaunchSpec {
return {
command: process.execPath.trim(),
args: [entry],
env: getPackagedElectronNodeEnv(),
};
}
function getWorkspaceMcpServerDir(): string { function getWorkspaceMcpServerDir(): string {
return path.join(getWorkspaceRoot(), 'mcp-server'); return path.join(getWorkspaceRoot(), 'mcp-server');
} }
@ -178,9 +202,11 @@ async function hasValidServerCopy(dir: string): Promise<boolean> {
} }
let _resolvedNodePath: string | undefined; let _resolvedNodePath: string | undefined;
let _packagedElectronNodeRuntimeProbe: { ok: true } | { ok: false; error: unknown } | undefined;
export function clearResolvedNodePathForTests(): void { export function clearResolvedNodePathForTests(): void {
_resolvedNodePath = undefined; _resolvedNodePath = undefined;
_packagedElectronNodeRuntimeProbe = undefined;
} }
function emitProgress( function emitProgress(
@ -301,6 +327,37 @@ async function probeNodeRuntimePath(
return { ok: false, error: lastError ?? 'no Node.js candidates were available' }; return { ok: false, error: lastError ?? 'no Node.js candidates were available' };
} }
async function probePackagedElectronNodeRuntime(
options?: McpLaunchSpecResolveOptions
): Promise<{ ok: true } | { ok: false; error: unknown }> {
if (_packagedElectronNodeRuntimeProbe) {
return _packagedElectronNodeRuntimeProbe;
}
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');
}
_packagedElectronNodeRuntimeProbe = { ok: true };
} catch (error) {
_packagedElectronNodeRuntimeProbe = { ok: false, error };
}
return _packagedElectronNodeRuntimeProbe;
}
async function probeShellNodeRuntimePath( async function probeShellNodeRuntimePath(
options?: McpLaunchSpecResolveOptions options?: McpLaunchSpecResolveOptions
): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> { ): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> {
@ -450,6 +507,27 @@ export async function resolveAgentTeamsMcpLaunchSpec(
const packagedEntry = await resolvePackagedServerEntry(options); const packagedEntry = await resolvePackagedServerEntry(options);
checked.push(packagedEntry); checked.push(packagedEntry);
if (await pathExists(packagedEntry)) { if (await pathExists(packagedEntry)) {
if (shouldUsePackagedElectronNodeRuntime()) {
const electronProbe = await probePackagedElectronNodeRuntime(options);
if (electronProbe.ok) {
emitProgress(
options,
'electron-node-runtime-found',
'Using bundled Electron Node runtime...'
);
return buildPackagedElectronNodeLaunchSpec(packagedEntry);
}
logger.warn(
`Bundled Electron Node runtime is unavailable for Agent Teams MCP; falling back to Node.js runtime: ${stringifyError(
electronProbe.error
)}`
);
emitProgress(
options,
'electron-node-runtime-fallback',
'Bundled Electron Node runtime unavailable, resolving Node.js fallback...'
);
}
return { return {
command: await resolveNodePath(options), command: await resolveNodePath(options),
args: [packagedEntry], args: [packagedEntry],
@ -520,6 +598,7 @@ export class TeamMcpConfigBuilder {
args: launchSpec.args, args: launchSpec.args,
enabled: true, enabled: true,
env: { env: {
...launchSpec.env,
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(), [MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
...(controlApiBaseUrl ? { [MCP_CONTROL_URL_ENV]: controlApiBaseUrl } : {}), ...(controlApiBaseUrl ? { [MCP_CONTROL_URL_ENV]: controlApiBaseUrl } : {}),
}, },

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,185 @@
import type {
TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberSpec,
TeamRuntimePendingApproval,
} from '../runtime/TeamRuntimeAdapter';
import type {
RuntimeApprovalLaunchPolicy,
RuntimeApprovalProviderPort,
RuntimeToolApprovalAnswerInput,
RuntimeToolApprovalEntry,
} from './RuntimeToolApprovalCoordinator';
import type { ToolApprovalRequest } from '@shared/types/team';
interface CollectOpenCodeRuntimeApprovalsInput {
teamName: string;
runId: string;
laneId: string;
cwd: string;
members: Record<string, TeamRuntimeMemberLaunchEvidence>;
expectedMembers: TeamRuntimeMemberSpec[];
teamColor?: string;
teamDisplayName?: string;
nowIso?: () => string;
}
export class OpenCodeRuntimeApprovalProvider implements RuntimeApprovalProviderPort<
{ toolApprovalMode?: 'auto' | 'manual' },
CollectOpenCodeRuntimeApprovalsInput
> {
readonly providerId = 'opencode' as const;
buildLaunchPolicy(
skipPermissions: boolean,
_context: { toolApprovalMode?: 'auto' | 'manual' } = {}
): RuntimeApprovalLaunchPolicy {
return {
providerId: this.providerId,
mode: skipPermissions ? 'auto' : 'manual',
config: {
permission: skipPermissions ? 'allow' : 'ask',
},
};
}
collectPendingApprovals(input: CollectOpenCodeRuntimeApprovalsInput): RuntimeToolApprovalEntry[] {
return collectOpenCodeRuntimeApprovalEntries(input);
}
async answerApproval(_input: RuntimeToolApprovalAnswerInput): Promise<void> {
throw new Error('OpenCode approval answers are handled by the runtime adapter bridge.');
}
assertManualSupported(): void {
return;
}
}
export const openCodeRuntimeApprovalProvider = new OpenCodeRuntimeApprovalProvider();
export function collectOpenCodeRuntimeApprovalEntries(
input: CollectOpenCodeRuntimeApprovalsInput
): RuntimeToolApprovalEntry[] {
const entries: RuntimeToolApprovalEntry[] = [];
const nowIso = input.nowIso ?? (() => new Date().toISOString());
for (const [memberName, member] of Object.entries(input.members)) {
for (const approval of collectOpenCodeRuntimePendingApprovals(member)) {
const providerRequestId = approval.requestId.trim();
if (!providerRequestId) {
continue;
}
const requestId = buildOpenCodeRuntimeApprovalRequestId(input.runId, providerRequestId);
const toolName = openCodeApprovalToolName(approval);
const toolInput = openCodeApprovalToolInput(approval);
const uiRequest: ToolApprovalRequest = {
requestId,
runId: input.runId,
teamName: input.teamName,
providerId: 'opencode',
source: memberName,
toolName,
toolInput,
receivedAt: nowIso(),
teamColor: input.teamColor,
teamDisplayName: input.teamDisplayName,
runtimePermission: {
providerId: 'opencode',
laneId: input.laneId,
memberName,
providerRequestId,
sessionId: approval.sessionId ?? member.sessionId ?? null,
},
};
entries.push({
providerId: 'opencode',
approval: uiRequest,
providerRequestId,
laneId: input.laneId,
memberName,
cwd: input.cwd,
expectedMembers: input.expectedMembers,
});
}
}
return entries;
}
function collectOpenCodeRuntimePendingApprovals(
member: TeamRuntimeMemberLaunchEvidence
): TeamRuntimePendingApproval[] {
const approvals = [...(member.pendingApprovals ?? []), ...(member.pendingPermissions ?? [])];
const byRequestId = new Map<string, TeamRuntimePendingApproval>();
for (const approval of approvals) {
const requestId = approval.requestId.trim();
if (!requestId || approval.providerId !== 'opencode' || byRequestId.has(requestId)) {
continue;
}
byRequestId.set(requestId, { ...approval, requestId });
}
for (const requestId of member.pendingPermissionRequestIds ?? []) {
const trimmed = requestId.trim();
if (!trimmed || byRequestId.has(trimmed)) {
continue;
}
byRequestId.set(trimmed, {
providerId: 'opencode',
requestId: trimmed,
sessionId: member.sessionId ?? null,
tool: null,
title: null,
kind: null,
});
}
return Array.from(byRequestId.values());
}
export function buildOpenCodeRuntimeApprovalRequestId(
runId: string,
providerRequestId: string
): string {
return `opencode:${runId}:${providerRequestId}`;
}
export function openCodeApprovalToolName(approval: TeamRuntimePendingApproval): string {
const rawTool = approval.tool?.trim() || approval.kind?.trim() || approval.title?.trim();
const normalized = rawTool?.toLowerCase();
switch (normalized) {
case 'bash':
case 'shell':
case 'terminal':
return 'Bash';
case 'edit':
return 'Edit';
case 'write':
return 'Write';
case 'read':
return 'Read';
default:
return rawTool || 'OpenCodeTool';
}
}
export function openCodeApprovalToolInput(
approval: TeamRuntimePendingApproval
): Record<string, unknown> {
const raw: Record<string, unknown> =
approval.raw && typeof approval.raw === 'object' ? approval.raw : {};
const patterns = Array.isArray(raw.patterns)
? raw.patterns.filter((value): value is string => typeof value === 'string')
: undefined;
const firstPattern = patterns?.[0];
const title = approval.title?.trim();
const input: Record<string, unknown> = {
providerRequestId: approval.requestId,
provider: 'opencode',
...(approval.sessionId ? { sessionId: approval.sessionId } : {}),
...(approval.tool ? { tool: approval.tool } : {}),
...(approval.kind ? { kind: approval.kind } : {}),
...(title ? { title } : {}),
...(patterns?.length ? { patterns } : {}),
};
if (openCodeApprovalToolName(approval) === 'Bash' && firstPattern) {
input.command = firstPattern;
}
return input;
}

View file

@ -0,0 +1,429 @@
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
import type {
TeamRuntimeApprovalProviderId,
TeamRuntimeMemberSpec,
} from '../runtime/TeamRuntimeAdapter';
import type {
ToolApprovalAutoResolved,
ToolApprovalDismiss,
ToolApprovalRequest,
ToolApprovalSettings,
} from '@shared/types/team';
export type RuntimeApprovalProviderId = TeamRuntimeApprovalProviderId;
export type RuntimeApprovalDecision = 'allow' | 'deny';
export interface RuntimeApprovalLaunchPolicy {
providerId: RuntimeApprovalProviderId;
mode: 'auto' | 'manual';
config: Record<string, unknown>;
}
export interface RuntimeApprovalProviderPort<TContext = unknown, TRuntimeState = unknown> {
readonly providerId: RuntimeApprovalProviderId;
buildLaunchPolicy(skipPermissions: boolean, context: TContext): RuntimeApprovalLaunchPolicy;
collectPendingApprovals(runtimeState: TRuntimeState): RuntimeToolApprovalEntry[];
answerApproval(input: RuntimeToolApprovalAnswerInput): Promise<void>;
assertManualSupported(context: TContext): void;
}
export interface RuntimeToolApprovalEntry {
providerId: RuntimeApprovalProviderId;
approval: ToolApprovalRequest;
providerRequestId: string;
laneId: string;
memberName: string;
cwd?: string;
expectedMembers?: TeamRuntimeMemberSpec[];
metadata?: Record<string, unknown>;
}
export interface RuntimeToolApprovalAnswerInput {
entry: RuntimeToolApprovalEntry;
allow: boolean;
message?: string;
}
export type RuntimeToolApprovalEvent =
| ToolApprovalRequest
| ToolApprovalDismiss
| ToolApprovalAutoResolved;
export interface RuntimeToolApprovalCoordinatorDeps {
getSettings(teamName: string): ToolApprovalSettings;
answerApproval(input: RuntimeToolApprovalAnswerInput): Promise<void>;
emitApprovalEvent(event: RuntimeToolApprovalEvent): void;
showApprovalNotification?(approval: ToolApprovalRequest): void;
dismissApprovalNotification?(requestId: string): void;
logWarning?(message: string): void;
}
export interface RuntimeToolApprovalSyncScope {
teamName: string;
runId: string;
laneId?: string;
providerId?: RuntimeApprovalProviderId;
}
export interface RuntimeToolApprovalClearOptions {
runId?: string;
laneId?: string;
providerId?: RuntimeApprovalProviderId;
emitDismiss?: boolean;
}
export function mapAppApprovalDecisionToProviderDecision(
decision: RuntimeApprovalDecision
): 'allow' | 'reject' {
return decision === 'allow' ? 'allow' : 'reject';
}
export class RuntimeToolApprovalCoordinator {
private readonly approvalsByTeam = new Map<string, Map<string, RuntimeToolApprovalEntry>>();
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly inFlightResponses = new Set<string>();
constructor(private readonly deps: RuntimeToolApprovalCoordinatorDeps) {}
sync(scope: RuntimeToolApprovalSyncScope, entries: RuntimeToolApprovalEntry[]): void {
const observedRequestIds = new Set<string>();
for (const entry of entries) {
observedRequestIds.add(entry.approval.requestId);
this.register(entry);
}
const approvals = this.approvalsByTeam.get(scope.teamName);
if (!approvals) {
return;
}
for (const [requestId, entry] of approvals) {
if (!this.matchesScope(entry, scope)) {
continue;
}
if (observedRequestIds.has(requestId)) {
continue;
}
this.removeEntry(entry);
this.deps.emitApprovalEvent({
autoResolved: true,
requestId,
runId: entry.approval.runId,
teamName: entry.approval.teamName,
reason: 'runtime_resolved',
} as ToolApprovalAutoResolved);
}
}
register(entry: RuntimeToolApprovalEntry): void {
const requestId = entry.approval.requestId;
if (!requestId) {
return;
}
const approvals = this.getTeamApprovals(entry.approval.teamName);
if (approvals.has(requestId) || this.inFlightResponses.has(requestId)) {
return;
}
const autoResult = shouldAutoAllow(
this.deps.getSettings(entry.approval.teamName),
entry.approval.toolName,
entry.approval.toolInput
);
if (autoResult.autoAllow) {
void this.answerUntracked(entry, true, undefined, 'auto_allow_category');
return;
}
approvals.set(requestId, entry);
this.deps.emitApprovalEvent(entry.approval);
this.startTimeout(entry);
this.deps.showApprovalNotification?.(entry.approval);
}
async respond(
teamName: string,
runId: string,
requestId: string,
allow: boolean,
message?: string
): Promise<boolean> {
const entry = this.approvalsByTeam.get(teamName)?.get(requestId);
if (!entry) {
return false;
}
if (entry.approval.runId !== runId) {
throw new Error(
`Stale approval: runId mismatch (expected ${entry.approval.runId}, got ${runId})`
);
}
this.clearTimer(requestId);
if (!this.tryClaimResponse(requestId)) {
return true;
}
try {
await this.deps.answerApproval({ entry, allow, message });
} catch (error) {
this.inFlightResponses.delete(requestId);
if (this.get(entry.approval.teamName, requestId) === entry) {
this.startTimeout(entry);
}
throw error;
}
this.removeEntry(entry);
this.inFlightResponses.delete(requestId);
return true;
}
clear(teamName: string, options: RuntimeToolApprovalClearOptions = {}): number {
const approvals = this.approvalsByTeam.get(teamName);
if (!approvals) {
return 0;
}
let removed = 0;
const removedRunIds = new Set<string>();
for (const entry of Array.from(approvals.values())) {
if (!this.matchesClearOptions(entry, options)) {
continue;
}
this.removeEntry(entry);
removed += 1;
removedRunIds.add(entry.approval.runId);
}
if (removed > 0 && options.emitDismiss) {
for (const runId of removedRunIds) {
this.deps.emitApprovalEvent({ dismissed: true, teamName, runId });
}
}
return removed;
}
reEvaluate(): void {
for (const approvals of Array.from(this.approvalsByTeam.values())) {
for (const entry of Array.from(approvals.values())) {
const requestId = entry.approval.requestId;
const settings = this.deps.getSettings(entry.approval.teamName);
const autoResult = shouldAutoAllow(
settings,
entry.approval.toolName,
entry.approval.toolInput
);
if (autoResult.autoAllow) {
this.clearTimer(requestId);
void this.answerTracked(entry, true, undefined, 'auto_allow_category');
continue;
}
if (settings.timeoutAction === 'wait') {
this.clearTimer(requestId);
} else if (!this.timers.has(requestId)) {
this.startTimeout(entry);
}
}
}
}
get(teamName: string, requestId: string): RuntimeToolApprovalEntry | undefined {
return this.approvalsByTeam.get(teamName)?.get(requestId);
}
size(teamName?: string): number {
if (teamName) {
return this.approvalsByTeam.get(teamName)?.size ?? 0;
}
let total = 0;
for (const approvals of this.approvalsByTeam.values()) {
total += approvals.size;
}
return total;
}
dispose(): void {
for (const requestId of Array.from(this.timers.keys())) {
this.clearTimer(requestId);
}
this.approvalsByTeam.clear();
this.inFlightResponses.clear();
}
private startTimeout(entry: RuntimeToolApprovalEntry): void {
const { timeoutAction, timeoutSeconds } = this.deps.getSettings(entry.approval.teamName);
if (timeoutAction === 'wait') {
return;
}
const requestId = entry.approval.requestId;
if (this.timers.has(requestId)) {
return;
}
const timer = setTimeout(() => {
this.timers.delete(requestId);
const current = this.get(entry.approval.teamName, requestId);
if (!current) {
return;
}
const currentAction = this.deps.getSettings(entry.approval.teamName).timeoutAction;
if (currentAction === 'wait') {
return;
}
const allow = currentAction === 'allow';
void this.answerTracked(
current,
allow,
allow ? undefined : 'Timed out - auto-denied by settings',
allow ? 'timeout_allow' : 'timeout_deny'
);
}, timeoutSeconds * 1000);
timer.unref?.();
this.timers.set(requestId, timer);
}
private async answerTracked(
entry: RuntimeToolApprovalEntry,
allow: boolean,
message: string | undefined,
reason: ToolApprovalAutoResolved['reason']
): Promise<void> {
const requestId = entry.approval.requestId;
if (!this.tryClaimResponse(requestId)) {
return;
}
try {
await this.deps.answerApproval({ entry, allow, message });
this.removeEntry(entry);
this.deps.emitApprovalEvent({
autoResolved: true,
requestId,
runId: entry.approval.runId,
teamName: entry.approval.teamName,
reason,
} as ToolApprovalAutoResolved);
} catch (error) {
this.deps.logWarning?.(
`[${entry.approval.teamName}] Failed to auto-resolve runtime approval ${requestId}: ${
error instanceof Error ? error.message : String(error)
}`
);
if (this.get(entry.approval.teamName, requestId) === entry) {
this.startTimeout(entry);
}
} finally {
this.inFlightResponses.delete(requestId);
}
}
private async answerUntracked(
entry: RuntimeToolApprovalEntry,
allow: boolean,
message: string | undefined,
reason: ToolApprovalAutoResolved['reason']
): Promise<void> {
const requestId = entry.approval.requestId;
if (!this.tryClaimResponse(requestId)) {
return;
}
try {
await this.deps.answerApproval({ entry, allow, message });
this.deps.emitApprovalEvent({
autoResolved: true,
requestId,
runId: entry.approval.runId,
teamName: entry.approval.teamName,
reason,
} as ToolApprovalAutoResolved);
} catch (error) {
this.deps.logWarning?.(
`[${entry.approval.teamName}] Failed to auto-resolve runtime approval ${requestId}: ${
error instanceof Error ? error.message : String(error)
}`
);
} finally {
this.inFlightResponses.delete(requestId);
}
}
private removeEntry(entry: RuntimeToolApprovalEntry): void {
const requestId = entry.approval.requestId;
this.clearTimer(requestId);
this.inFlightResponses.delete(requestId);
this.deps.dismissApprovalNotification?.(requestId);
const approvals = this.approvalsByTeam.get(entry.approval.teamName);
if (!approvals) {
return;
}
approvals.delete(requestId);
if (approvals.size === 0) {
this.approvalsByTeam.delete(entry.approval.teamName);
}
}
private clearTimer(requestId: string): void {
const timer = this.timers.get(requestId);
if (!timer) {
return;
}
clearTimeout(timer);
this.timers.delete(requestId);
}
private tryClaimResponse(requestId: string): boolean {
if (this.inFlightResponses.has(requestId)) {
return false;
}
this.inFlightResponses.add(requestId);
return true;
}
private getTeamApprovals(teamName: string): Map<string, RuntimeToolApprovalEntry> {
const existing = this.approvalsByTeam.get(teamName);
if (existing) {
return existing;
}
const approvals = new Map<string, RuntimeToolApprovalEntry>();
this.approvalsByTeam.set(teamName, approvals);
return approvals;
}
private matchesScope(
entry: RuntimeToolApprovalEntry,
scope: RuntimeToolApprovalSyncScope
): boolean {
if (entry.approval.teamName !== scope.teamName) {
return false;
}
if (entry.approval.runId !== scope.runId) {
return false;
}
if (scope.laneId && entry.laneId !== scope.laneId) {
return false;
}
if (scope.providerId && entry.providerId !== scope.providerId) {
return false;
}
return true;
}
private matchesClearOptions(
entry: RuntimeToolApprovalEntry,
options: RuntimeToolApprovalClearOptions
): boolean {
if (options.runId && entry.approval.runId !== options.runId) {
return false;
}
if (options.laneId && entry.laneId !== options.laneId) {
return false;
}
if (options.providerId && entry.providerId !== options.providerId) {
return false;
}
return true;
}
}

View file

@ -1,6 +1,6 @@
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
import { execCli } from '@main/utils/childProcess'; import { execCli } from '@main/utils/childProcess';
import { randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -38,6 +38,14 @@ export interface OpenCodeBridgeProcessRunner {
run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult>; run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult>;
} }
interface OpenCodeBridgeOutputReadResult {
content: string;
outputSource: 'stdout' | 'file' | 'none';
stdoutBytes: number;
outputFileBytes: number | null;
outputReadError: string | null;
}
export interface OpenCodeBridgeDiagnosticsSink { export interface OpenCodeBridgeDiagnosticsSink {
append(event: OpenCodeBridgeDiagnosticEvent): Promise<void>; append(event: OpenCodeBridgeDiagnosticEvent): Promise<void>;
} }
@ -60,6 +68,7 @@ const DEFAULT_STDERR_LIMIT_BYTES = 256_000;
const WINDOWS_BATCH_EXTENSIONS = new Set(['.cmd', '.bat']); const WINDOWS_BATCH_EXTENSIONS = new Set(['.cmd', '.bat']);
const EMPTY_STDOUT_READINESS_MAX_ATTEMPTS = 2; const EMPTY_STDOUT_READINESS_MAX_ATTEMPTS = 2;
const EMPTY_STDOUT_READINESS_RETRY_DELAY_MS = 250; const EMPTY_STDOUT_READINESS_RETRY_DELAY_MS = 250;
const SAFE_BRIDGE_INPUT_FILE_REQUEST_ID = /^[A-Za-z0-9._-]{1,120}$/;
export function resolveOpenCodeBridgeProcessCwd( export function resolveOpenCodeBridgeProcessCwd(
binaryPath: string, binaryPath: string,
@ -185,7 +194,16 @@ export class OpenCodeBridgeCommandClient {
stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES, stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES,
env: await this.resolveEnv(), env: await this.resolveEnv(),
}); });
const stdout = await this.readBridgeOutput(processResult.stdout, outputPath); const bridgeOutput = await this.readBridgeOutput(processResult.stdout, outputPath);
const processDetails = {
exitCode: processResult.exitCode,
timedOut: processResult.timedOut,
stdoutBytes: bridgeOutput.stdoutBytes,
stderrBytes: byteLength(processResult.stderr),
outputSource: bridgeOutput.outputSource,
outputFileBytes: bridgeOutput.outputFileBytes,
outputReadError: bridgeOutput.outputReadError,
};
if (processResult.timedOut) { if (processResult.timedOut) {
return this.contractFailure( return this.contractFailure(
@ -196,6 +214,7 @@ export class OpenCodeBridgeCommandClient {
{ {
stderr: redactBridgeDiagnosticText(processResult.stderr), stderr: redactBridgeDiagnosticText(processResult.stderr),
attempts: attempt, attempts: attempt,
...processDetails,
} }
); );
} }
@ -207,14 +226,14 @@ export class OpenCodeBridgeCommandClient {
'OpenCode bridge command failed', 'OpenCode bridge command failed',
true, true,
{ {
exitCode: processResult.exitCode,
stderr: redactBridgeDiagnosticText(processResult.stderr), stderr: redactBridgeDiagnosticText(processResult.stderr),
attempts: attempt, attempts: attempt,
...processDetails,
} }
); );
} }
const parsed = parseSingleBridgeJsonResult<TData>(stdout); const parsed = parseSingleBridgeJsonResult<TData>(bridgeOutput.content);
if (!parsed.ok) { if (!parsed.ok) {
if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) { if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) {
await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS); await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS);
@ -222,9 +241,10 @@ export class OpenCodeBridgeCommandClient {
} }
return this.contractFailure(envelope, 'contract_violation', parsed.error, false, { return this.contractFailure(envelope, 'contract_violation', parsed.error, false, {
stdoutPreview: redactBridgeDiagnosticText(stdout.slice(0, 2_000)), stdoutPreview: redactBridgeDiagnosticText(bridgeOutput.content.slice(0, 2_000)),
stderrPreview: redactBridgeDiagnosticText(processResult.stderr.slice(0, 2_000)), stderrPreview: redactBridgeDiagnosticText(processResult.stderr.slice(0, 2_000)),
attempts: attempt, attempts: attempt,
...processDetails,
}); });
} }
@ -232,6 +252,7 @@ export class OpenCodeBridgeCommandClient {
if (!validation.ok) { if (!validation.ok) {
return this.contractFailure(envelope, 'contract_violation', validation.reason, false, { return this.contractFailure(envelope, 'contract_violation', validation.reason, false, {
attempts: attempt, attempts: attempt,
...processDetails,
}); });
} }
@ -253,14 +274,56 @@ export class OpenCodeBridgeCommandClient {
} }
} }
private async readBridgeOutput(stdout: string, outputPath: string): Promise<string> { private async readBridgeOutput(
if (stdout.trim().length > 0) { stdout: string,
return stdout; outputPath: string
} ): Promise<OpenCodeBridgeOutputReadResult> {
const stdoutBytes = byteLength(stdout);
try { try {
return await fs.readFile(outputPath, 'utf8'); const output = await fs.readFile(outputPath, 'utf8');
} catch { const outputFileBytes = byteLength(output);
return stdout; if (output.trim().length > 0) {
return {
content: output,
outputSource: 'file',
stdoutBytes,
outputFileBytes,
outputReadError: null,
};
}
if (stdout.trim().length > 0) {
return {
content: stdout,
outputSource: 'stdout',
stdoutBytes,
outputFileBytes,
outputReadError: null,
};
}
return {
content: output,
outputSource: 'none',
stdoutBytes,
outputFileBytes,
outputReadError: null,
};
} catch (error) {
if (stdout.trim().length > 0) {
return {
content: stdout,
outputSource: 'stdout',
stdoutBytes,
outputFileBytes: 0,
outputReadError: getBridgeOutputReadError(error),
};
}
return {
content: stdout,
outputSource: 'none',
stdoutBytes,
outputFileBytes: 0,
outputReadError: getBridgeOutputReadError(error),
};
} }
} }
@ -275,7 +338,7 @@ export class OpenCodeBridgeCommandClient {
envelope: OpenCodeBridgeCommandEnvelope<TBody> envelope: OpenCodeBridgeCommandEnvelope<TBody>
): Promise<string> { ): Promise<string> {
await fs.mkdir(this.tempDirectory, { recursive: true, mode: 0o700 }); await fs.mkdir(this.tempDirectory, { recursive: true, mode: 0o700 });
const inputPath = path.join(this.tempDirectory, `opencode-command-${envelope.requestId}.json`); const inputPath = path.join(this.tempDirectory, buildBridgeInputFileName(envelope.requestId));
await fs.writeFile(inputPath, `${JSON.stringify(envelope, null, 2)}\n`, { await fs.writeFile(inputPath, `${JSON.stringify(envelope, null, 2)}\n`, {
encoding: 'utf8', encoding: 'utf8',
mode: 0o600, mode: 0o600,
@ -291,6 +354,13 @@ export class OpenCodeBridgeCommandClient {
details: Record<string, unknown> details: Record<string, unknown>
): Promise<OpenCodeBridgeFailure> { ): Promise<OpenCodeBridgeFailure> {
const completedAt = this.clock().toISOString(); const completedAt = this.clock().toISOString();
const diagnosticDetails = {
command: envelope.command,
requestId: envelope.requestId,
cwd: redactBridgeDiagnosticText(envelope.cwd),
binaryPath: redactBridgeDiagnosticText(this.binaryPath),
...details,
};
const diagnostic: OpenCodeBridgeDiagnosticEvent = { const diagnostic: OpenCodeBridgeDiagnosticEvent = {
id: this.diagnosticIdFactory(), id: this.diagnosticIdFactory(),
type: type:
@ -301,11 +371,11 @@ export class OpenCodeBridgeCommandClient {
runId: extractRunId(envelope.body) ?? undefined, runId: extractRunId(envelope.body) ?? undefined,
severity: retryable ? 'warning' : 'error', severity: retryable ? 'warning' : 'error',
message, message,
data: details, data: diagnosticDetails,
createdAt: completedAt, createdAt: completedAt,
}; };
await this.diagnostics?.append(diagnostic); await this.diagnostics?.append(diagnostic).catch(() => undefined);
return { return {
ok: false, ok: false,
@ -318,7 +388,7 @@ export class OpenCodeBridgeCommandClient {
kind, kind,
message, message,
retryable, retryable,
details, details: diagnosticDetails,
}, },
diagnostics: [diagnostic], diagnostics: [diagnostic],
}; };
@ -356,3 +426,38 @@ function bufferToString(value: string | Buffer | undefined): string {
} }
return ''; return '';
} }
function byteLength(value: string): number {
return Buffer.byteLength(value, 'utf8');
}
function buildBridgeInputFileName(requestId: string): string {
const trimmed = requestId.trim();
if (requestId === trimmed && SAFE_BRIDGE_INPUT_FILE_REQUEST_ID.test(trimmed)) {
return `opencode-command-${trimmed}.json`;
}
const sanitized =
Array.from(trimmed, (char) => (isUnsafeBridgeInputFileNameChar(char) ? '_' : char))
.join('')
.replace(/\s+/g, '_')
.replace(/_+/g, '_')
.replace(/^\.+/, '_')
.slice(0, 80) || 'request';
const fingerprint = createHash('sha256').update(requestId).digest('hex').slice(0, 12);
return `opencode-command-${sanitized}-${fingerprint}.json`;
}
function isUnsafeBridgeInputFileNameChar(char: string): boolean {
return char.charCodeAt(0) < 32 || '<>:"/\\|?*'.includes(char);
}
function getBridgeOutputReadError(error: unknown): string {
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as { code?: unknown }).code;
if (typeof code === 'string' && code.trim()) {
return code.trim();
}
}
return error instanceof Error ? error.message : String(error);
}

View file

@ -64,6 +64,7 @@ export interface OpenCodeLaunchTeamCommandBody {
teamName: string; teamName: string;
projectPath: string; projectPath: string;
selectedModel: string; selectedModel: string;
skipPermissions?: boolean;
members: OpenCodeTeamLaunchMemberCommandSpec[]; members: OpenCodeTeamLaunchMemberCommandSpec[];
leadPrompt: string; leadPrompt: string;
expectedCapabilitySnapshotId: string | null; expectedCapabilitySnapshotId: string | null;
@ -71,6 +72,15 @@ export interface OpenCodeLaunchTeamCommandBody {
capabilitySnapshotRecoveryAttemptId?: string; capabilitySnapshotRecoveryAttemptId?: string;
} }
export interface OpenCodeRuntimePermissionCommandData {
requestId: string;
sessionId: string | null;
tool: string | null;
title: string | null;
kind: string | null;
raw?: Record<string, unknown>;
}
export interface OpenCodeTeamMemberLaunchCommandData { export interface OpenCodeTeamMemberLaunchCommandData {
sessionId: string; sessionId: string;
launchState: OpenCodeTeamMemberLaunchBridgeState; launchState: OpenCodeTeamMemberLaunchBridgeState;
@ -78,6 +88,7 @@ export interface OpenCodeTeamMemberLaunchCommandData {
bootstrapMode?: OpenCodeBootstrapMode; bootstrapMode?: OpenCodeBootstrapMode;
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate; appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
pendingPermissionRequestIds?: string[]; pendingPermissionRequestIds?: string[];
pendingPermissions?: OpenCodeRuntimePermissionCommandData[];
diagnostics?: string[]; diagnostics?: string[];
model: string; model: string;
runtimePid?: number; runtimePid?: number;
@ -132,6 +143,30 @@ export interface OpenCodeStopTeamCommandData {
runtimeStoreManifestHighWatermark?: number | null; runtimeStoreManifestHighWatermark?: number | null;
} }
export interface OpenCodeAnswerPermissionCommandBody {
runId: string;
laneId: string;
teamId: string;
teamName: string;
projectPath: string;
memberName?: string;
requestId: string;
decision: 'allow' | 'always' | 'reject';
expectedCapabilitySnapshotId?: string | null;
manifestHighWatermark?: number | null;
}
export interface OpenCodeListRuntimePermissionsCommandBody {
teamId: string;
teamName: string;
laneId?: string;
projectPath?: string;
}
export interface OpenCodeListRuntimePermissionsCommandData {
permissions: OpenCodeRuntimePermissionCommandData[];
}
export interface OpenCodeCleanupHostsCommandBody { export interface OpenCodeCleanupHostsCommandBody {
reason: 'startup' | 'shutdown' | 'manual' | string; reason: 'startup' | 'shutdown' | 'manual' | string;
mode?: 'stale' | 'force'; mode?: 'stale' | 'force';
@ -590,6 +625,7 @@ export function assertBridgeResultCanMutateState<TData>(
command: OpenCodeBridgeCommandName; command: OpenCodeBridgeCommandName;
runId: string | null; runId: string | null;
capabilitySnapshotId: string | null; capabilitySnapshotId: string | null;
allowCapabilitySnapshotRecovery?: boolean;
} }
): asserts result is OpenCodeBridgeSuccess<TData> { ): asserts result is OpenCodeBridgeSuccess<TData> {
if (!result.ok) { if (!result.ok) {
@ -612,12 +648,28 @@ export function assertBridgeResultCanMutateState<TData>(
if ( if (
expected.capabilitySnapshotId !== null && expected.capabilitySnapshotId !== null &&
result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId &&
!(
expected.allowCapabilitySnapshotRecovery === true &&
hasOpenCodeBridgeDataDiagnosticCode(result.data, 'opencode_capability_snapshot_recovery')
)
) { ) {
throw new Error('OpenCode bridge capability snapshot mismatch'); throw new Error('OpenCode bridge capability snapshot mismatch');
} }
} }
function hasOpenCodeBridgeDataDiagnosticCode(value: unknown, code: string): boolean {
if (!isRecord(value) || !Array.isArray(value.diagnostics)) {
return false;
}
return value.diagnostics.some((diagnostic) => {
if (!isRecord(diagnostic)) {
return false;
}
return diagnostic.code === code;
});
}
export function validateOpenCodeBridgeHandshake(input: { export function validateOpenCodeBridgeHandshake(input: {
handshake: OpenCodeBridgeHandshake; handshake: OpenCodeBridgeHandshake;
expectedClient: OpenCodeBridgePeerIdentity; expectedClient: OpenCodeBridgePeerIdentity;
@ -744,6 +796,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
manifest: RuntimeStoreManifestEvidence; manifest: RuntimeStoreManifestEvidence;
idempotencyKey: string; idempotencyKey: string;
enforceManifestHighWatermark?: boolean; enforceManifestHighWatermark?: boolean;
allowCapabilitySnapshotRecovery?: boolean;
}): asserts input is { }): asserts input is {
result: OpenCodeBridgeSuccess<unknown>; result: OpenCodeBridgeSuccess<unknown>;
requestId: string; requestId: string;
@ -758,6 +811,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
command: input.command, command: input.command,
runId: input.runId, runId: input.runId,
capabilitySnapshotId: input.capabilitySnapshotId, capabilitySnapshotId: input.capabilitySnapshotId,
allowCapabilitySnapshotRecovery: input.allowCapabilitySnapshotRecovery,
}); });
const resultManifestHighWatermark = extractManifestHighWatermark(input.result.data); const resultManifestHighWatermark = extractManifestHighWatermark(input.result.data);

View file

@ -0,0 +1,143 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import { redactBridgeDiagnosticText } from './OpenCodeBridgeCommandClient';
import type { OpenCodeBridgeDiagnosticsSink } from './OpenCodeBridgeCommandClient';
import type { OpenCodeBridgeDiagnosticEvent } from './OpenCodeBridgeCommandContract';
const DEFAULT_MAX_EVENTS_BYTES = 3 * 1024 * 1024;
const MAX_STRING_CHARS = 4_000;
export interface OpenCodeBridgeDiagnosticsStoreOptions {
directory: string;
maxEventsBytes?: number;
}
export class OpenCodeBridgeDiagnosticsStore implements OpenCodeBridgeDiagnosticsSink {
private readonly directory: string;
private readonly maxEventsBytes: number;
constructor(options: OpenCodeBridgeDiagnosticsStoreOptions) {
this.directory = options.directory;
this.maxEventsBytes = options.maxEventsBytes ?? DEFAULT_MAX_EVENTS_BYTES;
}
async append(event: OpenCodeBridgeDiagnosticEvent): Promise<void> {
try {
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
const sanitized = sanitizeDiagnosticEvent(event);
await fs.writeFile(
path.join(this.directory, 'latest.json'),
`${JSON.stringify(sanitized, null, 2)}\n`,
{ encoding: 'utf8', mode: 0o600 }
);
await this.rotateEventsIfNeeded();
await fs.appendFile(
path.join(this.directory, 'events.ndjson'),
`${JSON.stringify(sanitized)}\n`,
{ encoding: 'utf8', mode: 0o600 }
);
} catch {
// Best-effort diagnostics must never block provider preflight or launch.
}
}
private async rotateEventsIfNeeded(): Promise<void> {
const eventsPath = path.join(this.directory, 'events.ndjson');
const stat = await fs.stat(eventsPath).catch(() => null);
if (!stat || stat.size <= this.maxEventsBytes) {
return;
}
const content = await fs.readFile(eventsPath, 'utf8').catch(() => '');
const keepBytes = Math.max(0, Math.floor(this.maxEventsBytes / 2));
const tailLines = selectNdjsonTailLines(content, keepBytes);
await fs.writeFile(
eventsPath,
`${JSON.stringify({
type: 'opencode_bridge_diagnostics_truncated',
providerId: 'opencode',
severity: 'info',
message: 'truncated previous bridge diagnostics',
createdAt: new Date().toISOString(),
})}\n${tailLines.length > 0 ? `${tailLines.join('\n')}\n` : ''}`,
{
encoding: 'utf8',
mode: 0o600,
}
);
}
}
function selectNdjsonTailLines(content: string, maxBytes: number): string[] {
if (maxBytes <= 0) {
return [];
}
const selected: string[] = [];
let totalBytes = 0;
const lines = content.split('\n').filter((line) => line.trim().length > 0);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
const nextBytes = Buffer.byteLength(`${line}\n`, 'utf8');
if (selected.length > 0 && totalBytes + nextBytes > maxBytes) {
break;
}
if (selected.length === 0 || totalBytes + nextBytes <= maxBytes) {
selected.unshift(line);
totalBytes += nextBytes;
}
}
return selected;
}
function sanitizeDiagnosticEvent(
event: OpenCodeBridgeDiagnosticEvent
): OpenCodeBridgeDiagnosticEvent {
return {
...event,
message: sanitizeString(event.message),
...(event.teamName ? { teamName: sanitizeString(event.teamName) } : {}),
...(event.runId ? { runId: sanitizeString(event.runId) } : {}),
...(event.data ? { data: sanitizeRecord(event.data) } : {}),
};
}
function sanitizeRecord(value: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, sanitizeRecordEntry(key, entry)])
);
}
function sanitizeRecordEntry(key: string, entry: unknown): unknown {
const normalized = key.toLowerCase();
if (
normalized === 'stdin' ||
normalized === 'stdout' ||
normalized === 'stderr' ||
normalized === 'input'
) {
return '[omitted]';
}
return sanitizeValue(entry);
}
function sanitizeValue(value: unknown): unknown {
if (typeof value === 'string') {
return sanitizeString(value);
}
if (Array.isArray(value)) {
return value.map((entry) => sanitizeValue(entry));
}
if (value && typeof value === 'object') {
return sanitizeRecord(value as Record<string, unknown>);
}
return value;
}
function sanitizeString(value: string): string {
const redacted = redactBridgeDiagnosticText(value);
return redacted.length > MAX_STRING_CHARS
? `${redacted.slice(0, MAX_STRING_CHARS)}...[truncated]`
: redacted;
}

View file

@ -0,0 +1,146 @@
import * as os from 'os';
import { redactBridgeDiagnosticText } from './OpenCodeBridgeCommandClient';
import type { OpenCodeBridgeFailure } from './OpenCodeBridgeCommandContract';
import type { TeamProvisioningSupportDiagnostic } from '@shared/types/team';
const NO_OUTPUT_TITLE = 'OpenCode runtime check returned no output';
const NO_OUTPUT_SUMMARY = 'OpenCode readiness bridge exited without returning diagnostic JSON.';
export function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean {
const lower = value?.trim().toLowerCase() ?? '';
return (
lower.includes('opencode runtime check returned no output') ||
lower.includes('bridge stdout was empty') ||
lower.includes('opencode_bridge_contract_violation') ||
(lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation'))
);
}
export function buildOpenCodeBridgeSupportDiagnostic(input: {
result: OpenCodeBridgeFailure;
projectPath: string;
selectedModel: string | null;
appVersion?: string | null;
}): TeamProvisioningSupportDiagnostic | null {
const event =
input.result.diagnostics.find((diagnostic) =>
isOpenCodeBridgeNoOutputDiagnostic(`${diagnostic.type}: ${diagnostic.message}`)
) ?? input.result.diagnostics[0];
const visibleError = `OpenCode readiness bridge failed: ${input.result.error.kind}: ${input.result.error.message}`;
const eventText = event ? `${event.type}: ${event.message}` : '';
if (
!isOpenCodeBridgeNoOutputDiagnostic(visibleError) &&
!isOpenCodeBridgeNoOutputDiagnostic(eventText)
) {
return null;
}
const details = {
...(event?.data ?? {}),
...(input.result.error.details ?? {}),
};
const createdAt = event?.createdAt ?? input.result.completedAt;
const copyText = buildOpenCodeBridgeSupportCopyText({
createdAt,
severity: event?.severity ?? (input.result.error.retryable ? 'warning' : 'error'),
visibleError,
details,
result: input.result,
projectPath: input.projectPath,
selectedModel: input.selectedModel,
appVersion: input.appVersion ?? null,
});
return {
id: event?.id ?? `opencode-bridge-support-${input.result.requestId}`,
providerId: 'opencode',
kind: 'opencode_bridge_no_output',
severity: event?.severity ?? (input.result.error.retryable ? 'warning' : 'error'),
title: NO_OUTPUT_TITLE,
summary: NO_OUTPUT_SUMMARY,
copyText,
createdAt,
};
}
function buildOpenCodeBridgeSupportCopyText(input: {
createdAt: string;
severity: 'info' | 'warning' | 'error';
visibleError: string;
details: Record<string, unknown>;
result: OpenCodeBridgeFailure;
projectPath: string;
selectedModel: string | null;
appVersion: string | null;
}): string {
const command = formatDiagnosticValue(input.details.command, input.result.command);
const requestId = formatDiagnosticValue(input.details.requestId, input.result.requestId);
const stderrPreview = formatPreview(input.details.stderrPreview);
return [
'Agent Teams OpenCode diagnostics',
`Time: ${input.createdAt}`,
'Provider: opencode',
`Severity: ${input.severity}`,
`Title: ${NO_OUTPUT_TITLE}`,
`Summary: ${NO_OUTPUT_SUMMARY}`,
'',
'Visible error:',
redactBridgeDiagnosticText(input.visibleError),
'',
'Bridge command:',
`command: ${command}`,
`requestId: ${requestId}`,
`attempts: ${formatDiagnosticValue(input.details.attempts)}`,
`exitCode: ${formatDiagnosticValue(input.details.exitCode)}`,
`timedOut: ${formatDiagnosticValue(input.details.timedOut)}`,
`stdoutBytes: ${formatDiagnosticValue(input.details.stdoutBytes)}`,
`stderrBytes: ${formatDiagnosticValue(input.details.stderrBytes)}`,
`outputSource: ${formatDiagnosticValue(input.details.outputSource)}`,
`outputFileBytes: ${formatDiagnosticValue(input.details.outputFileBytes)}`,
`outputReadError: ${formatDiagnosticValue(input.details.outputReadError)}`,
'',
'Environment:',
`platform: ${process.platform}`,
`arch: ${process.arch}`,
`appVersion: ${formatDiagnosticValue(input.appVersion)}`,
`projectPath: ${redactDiagnosticPath(input.projectPath)}`,
`selectedModel: ${formatDiagnosticValue(input.selectedModel)}`,
'',
'stderrPreview:',
stderrPreview,
].join('\n');
}
function formatDiagnosticValue(value: unknown, fallback: unknown = undefined): string {
const resolved = value ?? fallback;
if (resolved === null || resolved === undefined || resolved === '') {
return '(none)';
}
if (typeof resolved === 'string') {
return redactBridgeDiagnosticText(resolved);
}
if (typeof resolved === 'number' || typeof resolved === 'boolean') {
return String(resolved);
}
return redactBridgeDiagnosticText(JSON.stringify(resolved));
}
function formatPreview(value: unknown): string {
const formatted = formatDiagnosticValue(value);
return formatted === '(none)' ? '(empty)' : formatted;
}
function redactDiagnosticPath(value: string): string {
const home = os.homedir();
const trimmed = value.trim();
if (!trimmed) {
return '(none)';
}
if (home && trimmed.startsWith(home)) {
return `~${trimmed.slice(home.length)}`;
}
return redactBridgeDiagnosticText(trimmed);
}

View file

@ -5,6 +5,8 @@ const LOCAL_MCP_LAUNCH_ENV_KEYS = [
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY', 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY',
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON', 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON',
] as const; ] as const;
const OPTIONAL_LOCAL_MCP_LAUNCH_ENV_KEYS = ['CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON'] as const;
const LEGACY_LOCAL_MCP_CHILD_ENV_KEYS = ['ELECTRON_RUN_AS_NODE'] as const;
export type OpenCodeMcpBridgeEnv = Record<string, string | undefined>; export type OpenCodeMcpBridgeEnv = Record<string, string | undefined>;
@ -17,6 +19,17 @@ export function hasOpenCodeLocalMcpLaunchEnv(env: OpenCodeMcpBridgeEnv): boolean
return LOCAL_MCP_LAUNCH_ENV_KEYS.every((key) => Boolean(env[key]?.trim())); return LOCAL_MCP_LAUNCH_ENV_KEYS.every((key) => Boolean(env[key]?.trim()));
} }
function buildLegacyLocalMcpEnvJson(env: OpenCodeMcpBridgeEnv): string | null {
const legacyEnv: Record<string, string> = {};
for (const key of LEGACY_LOCAL_MCP_CHILD_ENV_KEYS) {
const value = env[key]?.trim();
if (value) {
legacyEnv[key] = value;
}
}
return Object.keys(legacyEnv).length > 0 ? JSON.stringify(legacyEnv) : null;
}
export function shouldEnsureOpenCodeLocalMcpLaunchEnv(input: { export function shouldEnsureOpenCodeLocalMcpLaunchEnv(input: {
httpBridgeEnabled: boolean; httpBridgeEnabled: boolean;
mcpUrl: string | undefined; mcpUrl: string | undefined;
@ -36,6 +49,20 @@ export function copyOpenCodeLocalMcpLaunchEnv(
delete targetEnv[key]; delete targetEnv[key];
} }
} }
for (const key of OPTIONAL_LOCAL_MCP_LAUNCH_ENV_KEYS) {
const value = sourceEnv[key]?.trim();
if (value) {
targetEnv[key] = value;
} else {
delete targetEnv[key];
}
}
if (!targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON?.trim()) {
const legacyEnvJson = buildLegacyLocalMcpEnvJson(sourceEnv);
if (legacyEnvJson) {
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON = legacyEnvJson;
}
}
} }
export function snapshotOpenCodeLocalMcpLaunchEnv( export function snapshotOpenCodeLocalMcpLaunchEnv(
@ -54,4 +81,10 @@ export function clearOpenCodeLocalMcpLaunchEnv(env: OpenCodeMcpBridgeEnv): void
for (const key of LOCAL_MCP_LAUNCH_ENV_KEYS) { for (const key of LOCAL_MCP_LAUNCH_ENV_KEYS) {
delete env[key]; delete env[key];
} }
for (const key of OPTIONAL_LOCAL_MCP_LAUNCH_ENV_KEYS) {
delete env[key];
}
for (const key of LEGACY_LOCAL_MCP_CHILD_ENV_KEYS) {
delete env[key];
}
} }

View file

@ -4,6 +4,7 @@ import {
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION, OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
stableHash, stableHash,
} from './OpenCodeBridgeCommandContract'; } from './OpenCodeBridgeCommandContract';
import { buildOpenCodeBridgeSupportDiagnostic } from './OpenCodeBridgeSupportDiagnostics';
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter'; import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
import type { import type {
@ -11,6 +12,7 @@ import type {
OpenCodeTeamLaunchReadinessState, OpenCodeTeamLaunchReadinessState,
} from '../readiness/OpenCodeTeamLaunchReadiness'; } from '../readiness/OpenCodeTeamLaunchReadiness';
import type { import type {
OpenCodeAnswerPermissionCommandBody,
OpenCodeBackfillTaskLedgerCommandBody, OpenCodeBackfillTaskLedgerCommandBody,
OpenCodeBackfillTaskLedgerCommandData, OpenCodeBackfillTaskLedgerCommandData,
OpenCodeBridgeCommandName, OpenCodeBridgeCommandName,
@ -24,6 +26,8 @@ import type {
OpenCodeCommandStatusCommandData, OpenCodeCommandStatusCommandData,
OpenCodeLaunchTeamCommandBody, OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData, OpenCodeLaunchTeamCommandData,
OpenCodeListRuntimePermissionsCommandBody,
OpenCodeListRuntimePermissionsCommandData,
OpenCodeObserveMessageDeliveryCommandBody, OpenCodeObserveMessageDeliveryCommandBody,
OpenCodeObserveMessageDeliveryCommandData, OpenCodeObserveMessageDeliveryCommandData,
OpenCodeReconcileTeamCommandBody, OpenCodeReconcileTeamCommandBody,
@ -62,6 +66,7 @@ export interface OpenCodeReadinessBridgeOptions {
observeTimeoutMs?: number; observeTimeoutMs?: number;
stopTimeoutMs?: number; stopTimeoutMs?: number;
cleanupTimeoutMs?: number; cleanupTimeoutMs?: number;
appVersion?: string;
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>; stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
} }
@ -80,6 +85,7 @@ const DEFAULT_SEND_TIMEOUT_MS = 45_000;
const DEFAULT_OBSERVE_TIMEOUT_MS = 20_000; const DEFAULT_OBSERVE_TIMEOUT_MS = 20_000;
const DEFAULT_STOP_TIMEOUT_MS = 30_000; const DEFAULT_STOP_TIMEOUT_MS = 30_000;
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000; const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
const DEFAULT_PERMISSION_TIMEOUT_MS = 30_000;
const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000; const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000;
const DEFAULT_COMMAND_STATUS_TIMEOUT_MS = 5_000; const DEFAULT_COMMAND_STATUS_TIMEOUT_MS = 5_000;
const OPEN_CODE_COMPLETED_COMMAND_RECOVERY_MESSAGE = const OPEN_CODE_COMPLETED_COMMAND_RECOVERY_MESSAGE =
@ -118,6 +124,12 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
} }
this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath); this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath);
const supportDiagnostic = buildOpenCodeBridgeSupportDiagnostic({
result,
projectPath: input.projectPath,
selectedModel: input.selectedModel,
appVersion: this.options.appVersion ?? null,
});
return blockedReadiness({ return blockedReadiness({
state: mapBridgeFailureToReadinessState(result.error.kind), state: mapBridgeFailureToReadinessState(result.error.kind),
modelId: input.selectedModel, modelId: input.selectedModel,
@ -126,6 +138,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
...result.diagnostics.map(formatDiagnosticEvent), ...result.diagnostics.map(formatDiagnosticEvent),
], ],
missing: [result.error.message], missing: [result.error.message],
supportDiagnostics: supportDiagnostic ? [supportDiagnostic] : undefined,
}); });
} }
@ -204,6 +217,40 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
}; };
} }
async answerOpenCodeRuntimePermission(
input: OpenCodeAnswerPermissionCommandBody
): Promise<OpenCodeLaunchTeamCommandData> {
const result = await this.executeStateChangingCommand<
OpenCodeAnswerPermissionCommandBody,
OpenCodeLaunchTeamCommandData
>('opencode.answerPermission', input, {
teamName: input.teamName,
laneId: input.laneId,
runId: input.runId,
capabilitySnapshotId: input.expectedCapabilitySnapshotId ?? null,
cwd: input.projectPath,
timeoutMs: DEFAULT_PERMISSION_TIMEOUT_MS,
});
return result.ok ? result.data : blockedLaunchData(input.runId, result);
}
async listOpenCodeRuntimePermissions(
input: OpenCodeListRuntimePermissionsCommandBody
): Promise<OpenCodeListRuntimePermissionsCommandData> {
const cwd = input.projectPath ?? process.cwd();
const result = await this.bridge.execute<
OpenCodeListRuntimePermissionsCommandBody,
OpenCodeListRuntimePermissionsCommandData
>('opencode.listRuntimePermissions', input, {
cwd,
timeoutMs: DEFAULT_PERMISSION_TIMEOUT_MS,
});
if (result.ok) {
return result.data;
}
return { permissions: [] };
}
async cleanupOpenCodeHosts( async cleanupOpenCodeHosts(
input: OpenCodeCleanupHostsCommandBody input: OpenCodeCleanupHostsCommandBody
): Promise<OpenCodeCleanupHostsCommandData> { ): Promise<OpenCodeCleanupHostsCommandData> {
@ -565,7 +612,11 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
type OpenCodeStateChangingTeamCommandName = Extract< type OpenCodeStateChangingTeamCommandName = Extract<
OpenCodeBridgeCommandName, OpenCodeBridgeCommandName,
'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' | 'opencode.sendMessage' | 'opencode.launchTeam'
| 'opencode.reconcileTeam'
| 'opencode.stopTeam'
| 'opencode.sendMessage'
| 'opencode.answerPermission'
>; >;
function blockedLaunchData( function blockedLaunchData(
@ -600,6 +651,7 @@ function blockedReadiness(input: {
modelId: string | null; modelId: string | null;
diagnostics: string[]; diagnostics: string[];
missing: string[]; missing: string[];
supportDiagnostics?: OpenCodeTeamLaunchReadiness['supportDiagnostics'];
}): OpenCodeTeamLaunchReadiness { }): OpenCodeTeamLaunchReadiness {
return { return {
state: input.state, state: input.state,
@ -617,6 +669,9 @@ function blockedReadiness(input: {
supportLevel: null, supportLevel: null,
missing: dedupe(input.missing), missing: dedupe(input.missing),
diagnostics: dedupe(input.diagnostics), diagnostics: dedupe(input.diagnostics),
...(input.supportDiagnostics?.length
? { supportDiagnostics: [...input.supportDiagnostics] }
: {}),
evidence: { evidence: {
capabilitiesReady: false, capabilitiesReady: false,
mcpToolProofRoute: null, mcpToolProofRoute: null,

View file

@ -245,6 +245,10 @@ export class OpenCodeStateChangingBridgeCommandService {
manifest, manifest,
idempotencyKey, idempotencyKey,
enforceManifestHighWatermark, enforceManifestHighWatermark,
allowCapabilitySnapshotRecovery: isOpenCodeLaunchCapabilitySnapshotRecoveryAttempt(
input.command,
input.body
),
}); });
} catch (error) { } catch (error) {
await this.ledger.markFailed({ await this.ledger.markFailed({
@ -354,6 +358,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
function isOpenCodeLaunchCapabilitySnapshotRecoveryAttempt(
command: OpenCodeBridgeCommandName,
body: unknown
): boolean {
if (command !== 'opencode.launchTeam' || !isRecord(body)) {
return false;
}
const recoveryAttemptId = body.capabilitySnapshotRecoveryAttemptId;
return typeof recoveryAttemptId === 'string' && recoveryAttemptId.trim().length > 0;
}
function requiresOpenCodeDeliveryAcceptanceContract( function requiresOpenCodeDeliveryAcceptanceContract(
command: OpenCodeBridgeCommandName, command: OpenCodeBridgeCommandName,
body: unknown body: unknown

View file

@ -11,6 +11,7 @@ import {
import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities'; import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities';
import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability'; import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability';
import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest'; import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest';
import type { TeamProvisioningSupportDiagnostic } from '@shared/types/team';
export type OpenCodeTeamLaunchReadinessState = export type OpenCodeTeamLaunchReadinessState =
| 'ready' | 'ready'
@ -57,6 +58,7 @@ export interface OpenCodeTeamLaunchReadiness {
supportLevel: OpenCodeSupportLevel | null; supportLevel: OpenCodeSupportLevel | null;
missing: string[]; missing: string[];
diagnostics: string[]; diagnostics: string[];
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
evidence: { evidence: {
capabilitiesReady: boolean; capabilitiesReady: boolean;
mcpToolProofRoute: OpenCodeMcpToolProof['route']; mcpToolProofRoute: OpenCodeMcpToolProof['route'];

View file

@ -1,12 +1,14 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import type { import type {
OpenCodeAnswerPermissionCommandBody,
OpenCodeBridgeRuntimeSnapshot, OpenCodeBridgeRuntimeSnapshot,
OpenCodeLaunchTeamCommandBody, OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData, OpenCodeLaunchTeamCommandData,
OpenCodeObserveMessageDeliveryCommandBody, OpenCodeObserveMessageDeliveryCommandBody,
OpenCodeObserveMessageDeliveryCommandData, OpenCodeObserveMessageDeliveryCommandData,
OpenCodeReconcileTeamCommandBody, OpenCodeReconcileTeamCommandBody,
OpenCodeRuntimePermissionCommandData,
OpenCodeSendMessageCommandBody, OpenCodeSendMessageCommandBody,
OpenCodeSendMessageCommandData, OpenCodeSendMessageCommandData,
OpenCodeStopTeamCommandBody, OpenCodeStopTeamCommandBody,
@ -20,6 +22,8 @@ import type {
TeamRuntimeLaunchResult, TeamRuntimeLaunchResult,
TeamRuntimeMemberLaunchEvidence, TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberStopEvidence, TeamRuntimeMemberStopEvidence,
TeamRuntimePendingPermission,
TeamRuntimePermissionAnswerInput,
TeamRuntimePrepareResult, TeamRuntimePrepareResult,
TeamRuntimeReconcileInput, TeamRuntimeReconcileInput,
TeamRuntimeReconcileResult, TeamRuntimeReconcileResult,
@ -52,6 +56,9 @@ export interface OpenCodeTeamRuntimeBridgePort {
observeOpenCodeTeamMessageDelivery?( observeOpenCodeTeamMessageDelivery?(
input: OpenCodeObserveMessageDeliveryCommandBody input: OpenCodeObserveMessageDeliveryCommandBody
): Promise<OpenCodeObserveMessageDeliveryCommandData>; ): Promise<OpenCodeObserveMessageDeliveryCommandData>;
answerOpenCodeRuntimePermission?(
input: OpenCodeAnswerPermissionCommandBody
): Promise<OpenCodeLaunchTeamCommandData>;
} }
export interface OpenCodeTeamRuntimeMessageInput { export interface OpenCodeTeamRuntimeMessageInput {
@ -199,6 +206,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
retryable: isRetryableReadinessState(readiness.state), retryable: isRetryableReadinessState(readiness.state),
diagnostics: mergeDiagnostics(readiness.diagnostics, readiness.missing), diagnostics: mergeDiagnostics(readiness.diagnostics, readiness.missing),
warnings: [], warnings: [],
...(readiness.supportDiagnostics?.length
? { supportDiagnostics: [...readiness.supportDiagnostics] }
: {}),
}; };
} }
@ -208,6 +218,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
modelId: readiness.modelId, modelId: readiness.modelId,
diagnostics: readiness.diagnostics, diagnostics: readiness.diagnostics,
warnings: [], warnings: [],
...(readiness.supportDiagnostics?.length
? { supportDiagnostics: [...readiness.supportDiagnostics] }
: {}),
}; };
} }
@ -242,7 +255,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
); );
} }
const skipReadinessPreflight = input.skipReadinessPreflight === true; // App-managed OpenCode launch requires a fresh capability snapshot from
// readiness before any state-changing bridge command can run.
const skipReadinessPreflight = false;
let selectedModel = input.model?.trim() ?? ''; let selectedModel = input.model?.trim() ?? '';
let launchWarnings: string[] = []; let launchWarnings: string[] = [];
if (!skipReadinessPreflight) { if (!skipReadinessPreflight) {
@ -290,6 +305,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
teamName: input.teamName, teamName: input.teamName,
projectPath: input.cwd, projectPath: input.cwd,
selectedModel: model, selectedModel: model,
skipPermissions: input.skipPermissions,
members: input.expectedMembers.map((member) => ({ members: input.expectedMembers.map((member) => ({
name: member.name, name: member.name,
role: member.role?.trim() || member.workflow?.trim() || 'teammate', role: member.role?.trim() || member.workflow?.trim() || 'teammate',
@ -547,6 +563,42 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
}; };
} }
async answerRuntimePermission(
input: TeamRuntimePermissionAnswerInput
): Promise<TeamRuntimeLaunchResult> {
if (!this.bridge.answerOpenCodeRuntimePermission) {
throw new Error('OpenCode permission answer bridge is not registered.');
}
const data = await this.bridge.answerOpenCodeRuntimePermission({
runId: input.runId,
laneId: input.laneId?.trim() || 'primary',
teamId: input.teamName,
teamName: input.teamName,
projectPath: input.cwd,
memberName: input.memberName,
requestId: input.requestId,
decision: input.decision,
expectedCapabilitySnapshotId: null,
manifestHighWatermark: null,
});
return mapOpenCodeLaunchDataToRuntimeResult(
{
runId: input.runId,
teamName: input.teamName,
laneId: input.laneId,
cwd: input.cwd,
providerId: this.providerId,
skipPermissions: false,
expectedMembers: input.expectedMembers,
previousLaunchState: input.previousLaunchState,
},
data,
[]
);
}
async stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult> { async stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult> {
if (this.bridge.stopOpenCodeTeam) { if (this.bridge.stopOpenCodeTeam) {
const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName); const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
@ -701,6 +753,7 @@ function mapOpenCodeLaunchDataToRuntimeResult(
bridgeMember?.model, bridgeMember?.model,
bridgeMember?.runtimePid, bridgeMember?.runtimePid,
bridgeMember?.pendingPermissionRequestIds, bridgeMember?.pendingPermissionRequestIds,
bridgeMember?.pendingPermissions,
bridgeMember != null, bridgeMember != null,
memberDiagnostics, memberDiagnostics,
input.runId, input.runId,
@ -798,6 +851,33 @@ function normalizeAppManagedBootstrapCandidate(
}; };
} }
function normalizeOpenCodeRuntimePendingPermissions(
permissions: OpenCodeRuntimePermissionCommandData[] | undefined
): TeamRuntimePendingPermission[] | undefined {
if (!permissions?.length) {
return undefined;
}
const normalized: TeamRuntimePendingPermission[] = [];
const seen = new Set<string>();
for (const permission of permissions) {
const requestId = permission.requestId?.trim();
if (!requestId || seen.has(requestId)) {
continue;
}
seen.add(requestId);
normalized.push({
providerId: 'opencode',
requestId,
sessionId: permission.sessionId ?? null,
tool: permission.tool ?? null,
title: permission.title ?? null,
kind: permission.kind ?? null,
...(permission.raw ? { raw: permission.raw } : {}),
});
}
return normalized.length > 0 ? normalized : undefined;
}
function mapBridgeMemberToRuntimeEvidence( function mapBridgeMemberToRuntimeEvidence(
memberName: string, memberName: string,
launchState: OpenCodeTeamMemberLaunchBridgeState, launchState: OpenCodeTeamMemberLaunchBridgeState,
@ -805,6 +885,7 @@ function mapBridgeMemberToRuntimeEvidence(
model: string | undefined, model: string | undefined,
runtimePid: number | undefined, runtimePid: number | undefined,
pendingPermissionRequestIds: string[] | undefined, pendingPermissionRequestIds: string[] | undefined,
pendingPermissions: OpenCodeRuntimePermissionCommandData[] | undefined,
runtimeMaterialized: boolean, runtimeMaterialized: boolean,
diagnostics: string[], diagnostics: string[],
runId: string, runId: string,
@ -863,6 +944,7 @@ function mapBridgeMemberToRuntimeEvidence(
: pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized : pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized
? 'warning' ? 'warning'
: undefined; : undefined;
const normalizedPendingApprovals = normalizeOpenCodeRuntimePendingPermissions(pendingPermissions);
return { return {
memberName, memberName,
providerId: 'opencode', providerId: 'opencode',
@ -887,6 +969,8 @@ function mapBridgeMemberToRuntimeEvidence(
pendingPermissionRequestIds && pendingPermissionRequestIds.length > 0 pendingPermissionRequestIds && pendingPermissionRequestIds.length > 0
? [...new Set(pendingPermissionRequestIds)] ? [...new Set(pendingPermissionRequestIds)]
: undefined, : undefined,
pendingApprovals: normalizedPendingApprovals,
pendingPermissions: normalizedPendingApprovals,
sessionId, sessionId,
...(appManagedCandidatePresent ...(appManagedCandidatePresent
? { bootstrapEvidenceSource: 'app_managed_bootstrap' as const } ? { bootstrapEvidenceSource: 'app_managed_bootstrap' as const }

View file

@ -11,6 +11,7 @@ import type {
TeamAgentRuntimeLivenessKind, TeamAgentRuntimeLivenessKind,
TeamAgentRuntimePidSource, TeamAgentRuntimePidSource,
TeamLaunchAggregateState, TeamLaunchAggregateState,
TeamProvisioningSupportDiagnostic,
} from '@shared/types'; } from '@shared/types';
export const TEAM_RUNTIME_PROVIDER_IDS = ['anthropic', 'codex', 'gemini', 'opencode'] as const; export const TEAM_RUNTIME_PROVIDER_IDS = ['anthropic', 'codex', 'gemini', 'opencode'] as const;
@ -28,6 +29,33 @@ export interface TeamRuntimeMemberSpec {
cwd: string; cwd: string;
} }
export type TeamRuntimeApprovalProviderId = 'anthropic' | 'opencode' | 'codex';
export interface TeamRuntimePendingApproval {
providerId: Extract<TeamRuntimeApprovalProviderId, 'opencode' | 'codex'>;
requestId: string;
sessionId?: string | null;
tool?: string | null;
title?: string | null;
kind?: string | null;
raw?: Record<string, unknown>;
}
export type TeamRuntimePendingPermission = TeamRuntimePendingApproval;
export interface TeamRuntimePermissionAnswerInput {
runId: string;
teamName: string;
laneId?: string;
cwd: string;
providerId: Extract<TeamRuntimeApprovalProviderId, 'opencode' | 'codex'>;
memberName: string;
requestId: string;
decision: 'allow' | 'reject';
expectedMembers: TeamRuntimeMemberSpec[];
previousLaunchState: PersistedTeamLaunchSnapshot | null;
}
export interface TeamRuntimeLaunchInput { export interface TeamRuntimeLaunchInput {
runId: string; runId: string;
teamName: string; teamName: string;
@ -58,6 +86,7 @@ export interface TeamRuntimePrepareSuccess {
modelId: string | null; modelId: string | null;
diagnostics: string[]; diagnostics: string[];
warnings: string[]; warnings: string[];
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
} }
export interface TeamRuntimePrepareFailure { export interface TeamRuntimePrepareFailure {
@ -67,6 +96,7 @@ export interface TeamRuntimePrepareFailure {
diagnostics: string[]; diagnostics: string[];
warnings: string[]; warnings: string[];
retryable: boolean; retryable: boolean;
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
} }
export type TeamRuntimePrepareResult = TeamRuntimePrepareSuccess | TeamRuntimePrepareFailure; export type TeamRuntimePrepareResult = TeamRuntimePrepareSuccess | TeamRuntimePrepareFailure;
@ -82,6 +112,8 @@ export interface TeamRuntimeMemberLaunchEvidence {
hardFailure: boolean; hardFailure: boolean;
hardFailureReason?: string; hardFailureReason?: string;
pendingPermissionRequestIds?: string[]; pendingPermissionRequestIds?: string[];
pendingApprovals?: TeamRuntimePendingApproval[];
pendingPermissions?: TeamRuntimePendingApproval[];
sessionId?: string; sessionId?: string;
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource; bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
bootstrapMode?: OpenCodeBootstrapMode; bootstrapMode?: OpenCodeBootstrapMode;
@ -171,6 +203,9 @@ export interface TeamLaunchRuntimeAdapter {
launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult>; launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult>;
reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult>; reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult>;
stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult>; stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult>;
answerRuntimePermission?(
input: TeamRuntimePermissionAnswerInput
): Promise<TeamRuntimeLaunchResult>;
} }
export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId { export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId {

View file

@ -6,11 +6,14 @@ export type {
export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter'; export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter';
export type { export type {
TeamLaunchRuntimeAdapter, TeamLaunchRuntimeAdapter,
TeamRuntimeApprovalProviderId,
TeamRuntimeLaunchInput, TeamRuntimeLaunchInput,
TeamRuntimeLaunchResult, TeamRuntimeLaunchResult,
TeamRuntimeMemberLaunchEvidence, TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberSpec, TeamRuntimeMemberSpec,
TeamRuntimeMemberStopEvidence, TeamRuntimeMemberStopEvidence,
TeamRuntimePendingApproval,
TeamRuntimePendingPermission,
TeamRuntimePrepareFailure, TeamRuntimePrepareFailure,
TeamRuntimePrepareResult, TeamRuntimePrepareResult,
TeamRuntimePrepareSuccess, TeamRuntimePrepareSuccess,

View file

@ -231,6 +231,20 @@ function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindows
}; };
} }
function resolveNpmNativeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
const nativeTarget = /(?:^|[&|])\s*"([^"]+\.(?:exe|com))"\s+%\*/im.exec(content)?.[1];
if (!nativeTarget) {
return null;
}
const target = resolveCmdPathTemplate(nativeTarget, launcherDir);
if (!existsSync(target)) {
return null;
}
return { command: target, argsPrefix: [] };
}
/** /**
* Some Windows launchers are thin wrappers around a real JS entrypoint. * Some Windows launchers are thin wrappers around a real JS entrypoint.
* Running that entrypoint directly with an argv array avoids cmd.exe's * Running that entrypoint directly with an argv array avoids cmd.exe's
@ -245,7 +259,9 @@ function resolveDirectWindowsLauncher(binaryPath: string): DirectWindowsLauncher
const content = readFileSync(binaryPath, 'utf8'); const content = readFileSync(binaryPath, 'utf8');
const launcherDir = path.dirname(binaryPath); const launcherDir = path.dirname(binaryPath);
return ( return (
resolveGeneratedBunLauncher(content, launcherDir) ?? resolveNpmNodeShim(content, launcherDir) resolveGeneratedBunLauncher(content, launcherDir) ??
resolveNpmNodeShim(content, launcherDir) ??
resolveNpmNativeShim(content, launcherDir)
); );
} catch { } catch {
return null; return null;

View file

@ -0,0 +1,223 @@
import { execFile } from 'child_process';
import { win32 as pathWin32 } from 'path';
import type { WindowsElevationStatus } from '@shared/types/api';
const DEFAULT_WINDOWS_ELEVATION_TIMEOUT_MS = 3_000;
const DEFAULT_WINDOWS_SYSTEM_ROOT = 'C:\\Windows';
const NON_ELEVATED_FLTMC_PATTERNS = [
/\baccess\s+is\s+denied\b/i,
/\baccess\s+denied\b/i,
/\boperation\s+not\s+permitted\b/i,
/requires?\s+elevation/i,
/\belevated\b/i,
/\badministrator\b/i,
/\bprivileges?\b/i,
/\bpermission\s+denied\b/i,
/not\s+held\s+by\s+the\s+client/i,
];
const PROBE_UNAVAILABLE_PATTERNS = [
/cannot\s+find/i,
/not\s+found/i,
/not\s+recognized/i,
/not\s+a\s+valid\s+win32\s+application/i,
/bad\s+exe\s+format/i,
];
export interface WindowsElevationCommandResult {
error: unknown;
stderr?: string | Buffer | null;
}
export interface WindowsElevationCommandOptions {
timeoutMs: number;
}
export type WindowsElevationCommandRunner = (
command: string,
options: WindowsElevationCommandOptions
) => Promise<WindowsElevationCommandResult>;
export interface WindowsElevationStatusCheckerOptions {
platform?: string;
arch?: string;
systemRoot?: string;
timeoutMs?: number;
runCommand?: WindowsElevationCommandRunner;
}
let cachedWindowsElevationStatus: Promise<WindowsElevationStatus> | null = null;
function createStatus(
platform: string,
isAdministrator: boolean | null,
checkFailed: boolean,
error: string | null = null
): WindowsElevationStatus {
return {
platform,
isWindows: platform === 'win32',
isAdministrator,
checkFailed,
error,
};
}
function readErrorField(error: unknown, field: string): unknown {
if (!error || typeof error !== 'object' || !(field in error)) {
return undefined;
}
return (error as Record<string, unknown>)[field];
}
function getErrorCode(error: unknown): string | number | null {
const code = readErrorField(error, 'code');
return typeof code === 'string' || typeof code === 'number' ? code : null;
}
function wasKilledOrTimedOut(error: unknown): boolean {
const killed = readErrorField(error, 'killed');
const signal = readErrorField(error, 'signal');
const code = getErrorCode(error);
return killed === true || signal === 'SIGTERM' || code === 'ETIMEDOUT';
}
function isMissingCommand(error: unknown): boolean {
return getErrorCode(error) === 'ENOENT';
}
function toCappedString(value: unknown): string | null {
if (typeof value === 'string') {
return value.slice(0, 500);
}
if (Buffer.isBuffer(value)) {
return value.toString('utf8').slice(0, 500);
}
return null;
}
function getErrorMessage(error: unknown, stderr: unknown): string | null {
const stderrText = toCappedString(stderr)?.trim();
if (stderrText) {
return stderrText;
}
if (error instanceof Error && error.message.trim()) {
return error.message.slice(0, 500);
}
return null;
}
function getCombinedErrorText(error: unknown, stderr: unknown): string {
return [getErrorMessage(error, null), toCappedString(stderr)]
.filter((part): part is string => Boolean(part?.trim()))
.join('\n');
}
function looksLikeNonElevatedFltmcError(error: unknown, stderr: unknown): boolean {
const code = getErrorCode(error);
const combined = getCombinedErrorText(error, stderr);
if (PROBE_UNAVAILABLE_PATTERNS.some((pattern) => pattern.test(combined))) {
return false;
}
if (code === 1 || code === '1' || code === 5 || code === '5') {
return true;
}
return NON_ELEVATED_FLTMC_PATTERNS.some((pattern) => pattern.test(combined));
}
function normalizeWindowsRoot(value: string | null | undefined): string | null {
const normalized = value?.trim().replace(/^['"]|['"]$/g, '');
if (!normalized || !pathWin32.isAbsolute(normalized)) {
return null;
}
return normalized;
}
function resolveWindowsSystemRoot(explicitSystemRoot: string | undefined): string {
if (explicitSystemRoot !== undefined) {
return normalizeWindowsRoot(explicitSystemRoot) ?? DEFAULT_WINDOWS_SYSTEM_ROOT;
}
return (
normalizeWindowsRoot(process.env.SystemRoot) ??
normalizeWindowsRoot(process.env.windir) ??
normalizeWindowsRoot(process.env.WINDIR) ??
DEFAULT_WINDOWS_SYSTEM_ROOT
);
}
function getFltmcPathCandidates(systemRoot: string, arch: string): string[] {
const systemDirs = arch === 'ia32' ? ['Sysnative', 'System32'] : ['System32'];
return systemDirs.map((dir) => pathWin32.join(systemRoot, dir, 'fltmc.exe'));
}
function runFltmc(command: string, options: WindowsElevationCommandOptions) {
return new Promise<WindowsElevationCommandResult>((resolve) => {
execFile(
command,
[],
{ timeout: options.timeoutMs, windowsHide: true },
(error, _stdout, stderr) => {
resolve({ error, stderr });
}
);
});
}
export function createWindowsElevationStatusChecker(
options: WindowsElevationStatusCheckerOptions = {}
): () => Promise<WindowsElevationStatus> {
const platform = options.platform ?? process.platform;
const arch = options.arch ?? process.arch;
const systemRoot = resolveWindowsSystemRoot(options.systemRoot);
const timeoutMs = options.timeoutMs ?? DEFAULT_WINDOWS_ELEVATION_TIMEOUT_MS;
const runCommand = options.runCommand ?? runFltmc;
return async () => {
if (platform !== 'win32') {
return createStatus(platform, null, false);
}
for (const command of getFltmcPathCandidates(systemRoot, arch)) {
let result: WindowsElevationCommandResult;
try {
result = await runCommand(command, { timeoutMs });
} catch (error) {
if (isMissingCommand(error)) {
continue;
}
return createStatus(platform, null, true, getErrorMessage(error, null));
}
if (!result.error) {
return createStatus(platform, true, false);
}
const message = getErrorMessage(result.error, result.stderr);
if (isMissingCommand(result.error)) {
continue;
}
if (wasKilledOrTimedOut(result.error)) {
return createStatus(platform, null, true, message);
}
if (looksLikeNonElevatedFltmcError(result.error, result.stderr)) {
return createStatus(platform, false, false, message);
}
return createStatus(platform, null, true, message);
}
return createStatus(platform, null, true, 'Windows elevation probe command was not found.');
};
}
export function getWindowsElevationStatus(): Promise<WindowsElevationStatus> {
cachedWindowsElevationStatus ??= createWindowsElevationStatusChecker()();
return cachedWindowsElevationStatus;
}
export function resetWindowsElevationStatusCacheForTests(): void {
cachedWindowsElevationStatus = null;
}

View file

@ -23,6 +23,9 @@ export const APP_STARTUP_GET_STATUS = 'appStartup:getStatus';
/** Main -> renderer startup progress update */ /** Main -> renderer startup progress update */
export const APP_STARTUP_PROGRESS = 'appStartup:progress'; export const APP_STARTUP_PROGRESS = 'appStartup:progress';
/** Renderer -> main Windows elevation status request */
export const APP_GET_WINDOWS_ELEVATION_STATUS = 'app:getWindowsElevationStatus';
// ============================================================================= // =============================================================================
// Telemetry Channels // Telemetry Channels
// ============================================================================= // =============================================================================

View file

@ -14,6 +14,7 @@ import {
API_KEYS_LOOKUP, API_KEYS_LOOKUP,
API_KEYS_SAVE, API_KEYS_SAVE,
API_KEYS_STORAGE_STATUS, API_KEYS_STORAGE_STATUS,
APP_GET_WINDOWS_ELEVATION_STATUS,
APP_RELAUNCH, APP_RELAUNCH,
APP_STARTUP_GET_STATUS, APP_STARTUP_GET_STATUS,
APP_STARTUP_PROGRESS, APP_STARTUP_PROGRESS,
@ -350,6 +351,7 @@ import type {
TriggerTestResult, TriggerTestResult,
UpdateKanbanPatch, UpdateKanbanPatch,
UpdateSchedulePatch, UpdateSchedulePatch,
WindowsElevationStatus,
WslClaudeRootCandidate, WslClaudeRootCandidate,
} from '@shared/types'; } from '@shared/types';
import type { import type {
@ -514,6 +516,8 @@ const electronAPI: ElectronAPI = {
}, },
}, },
getAppVersion: () => ipcRenderer.invoke('get-app-version'), getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getWindowsElevationStatus: () =>
ipcRenderer.invoke(APP_GET_WINDOWS_ELEVATION_STATUS) as Promise<WindowsElevationStatus>,
getProjects: () => ipcRenderer.invoke('get-projects'), getProjects: () => ipcRenderer.invoke('get-projects'),
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
getSessionsPaginated: ( getSessionsPaginated: (

View file

@ -98,6 +98,7 @@ import type {
UpdaterAPI, UpdaterAPI,
UpdateSchedulePatch, UpdateSchedulePatch,
WaterfallData, WaterfallData,
WindowsElevationStatus,
WslClaudeRootCandidate, WslClaudeRootCandidate,
} from '@shared/types'; } from '@shared/types';
import type { AgentConfig, MemberWorkSyncElectronApi } from '@shared/types/api'; import type { AgentConfig, MemberWorkSyncElectronApi } from '@shared/types/api';
@ -244,6 +245,14 @@ export class HttpAPIClient implements ElectronAPI {
getAppVersion = (): Promise<string> => this.get<string>('/api/version'); getAppVersion = (): Promise<string> => this.get<string>('/api/version');
getWindowsElevationStatus = async (): Promise<WindowsElevationStatus> => ({
platform: 'browser',
isWindows: false,
isAdministrator: null,
checkFailed: false,
error: null,
});
getCodexAccountSnapshot = (): Promise<CodexAccountSnapshotDto> => getCodexAccountSnapshot = (): Promise<CodexAccountSnapshotDto> =>
Promise.reject(new Error('Codex account bridge is unavailable in browser mode')); Promise.reject(new Error('Codex account bridge is unavailable in browser mode'));

View file

@ -16,6 +16,7 @@ import { CliStatusBanner } from './CliStatusBanner';
import { DashboardUpdateBanner } from './DashboardUpdateBanner'; import { DashboardUpdateBanner } from './DashboardUpdateBanner';
import { TmuxStatusBanner } from './TmuxStatusBanner'; import { TmuxStatusBanner } from './TmuxStatusBanner';
import { WebPreviewBanner } from './WebPreviewBanner'; import { WebPreviewBanner } from './WebPreviewBanner';
import { WindowsAdministratorBanner } from './WindowsAdministratorBanner';
interface CommandSearchProps { interface CommandSearchProps {
value: string; value: string;
@ -114,6 +115,7 @@ export const DashboardView = (): React.JSX.Element => {
<div className="relative mx-auto max-w-5xl px-8 py-12"> <div className="relative mx-auto max-w-5xl px-8 py-12">
<WebPreviewBanner /> <WebPreviewBanner />
<WindowsAdministratorBanner />
<DashboardUpdateBanner /> <DashboardUpdateBanner />
<CliStatusBanner /> <CliStatusBanner />
<TmuxStatusBanner /> <TmuxStatusBanner />

View file

@ -0,0 +1,216 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WindowsAdministratorBanner } from './WindowsAdministratorBanner';
import type { WindowsElevationStatus } from '@shared/types/api';
function createStatus(overrides: Partial<WindowsElevationStatus> = {}): WindowsElevationStatus {
return {
platform: 'win32',
isWindows: true,
isAdministrator: false,
checkFailed: false,
error: null,
...overrides,
};
}
function installElevationStatus(status: WindowsElevationStatus) {
return installElevationStatusPromise(Promise.resolve(status));
}
function installElevationStatusPromise(promise: Promise<WindowsElevationStatus>) {
const getWindowsElevationStatus = vi.fn().mockReturnValue(promise);
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
getWindowsElevationStatus,
},
});
return getWindowsElevationStatus;
}
function installElevationStatusFailure() {
const getWindowsElevationStatus = vi.fn().mockRejectedValue(new Error('IPC failed'));
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {
getWindowsElevationStatus,
},
});
return getWindowsElevationStatus;
}
async function flushReact(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe('WindowsAdministratorBanner', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
});
afterEach(() => {
document.body.innerHTML = '';
Reflect.deleteProperty(window, 'electronAPI');
vi.unstubAllGlobals();
});
it('shows a Windows Administrator warning when the app is not elevated', async () => {
const getWindowsElevationStatus = installElevationStatus(createStatus());
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(WindowsAdministratorBanner));
await flushReact();
});
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
expect(host.textContent).toContain('Windows Administrator mode recommended');
expect(host.textContent).toContain('Run as administrator');
await act(async () => {
root.unmount();
await flushReact();
});
});
it('hides the warning when Windows is already elevated', async () => {
const getWindowsElevationStatus = installElevationStatus(
createStatus({ isAdministrator: true })
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(WindowsAdministratorBanner));
await flushReact();
});
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
expect(host.textContent).toBe('');
await act(async () => {
root.unmount();
await flushReact();
});
});
it('hides the warning outside Windows', async () => {
const getWindowsElevationStatus = installElevationStatus(
createStatus({ platform: 'darwin', isWindows: false, isAdministrator: null })
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(WindowsAdministratorBanner));
await flushReact();
});
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
expect(host.textContent).toBe('');
await act(async () => {
root.unmount();
await flushReact();
});
});
it('hides the warning when the status check is inconclusive', async () => {
const getWindowsElevationStatus = installElevationStatus(
createStatus({ isAdministrator: null, checkFailed: true, error: 'probe unavailable' })
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(WindowsAdministratorBanner));
await flushReact();
});
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
expect(host.textContent).toBe('');
await act(async () => {
root.unmount();
await flushReact();
});
});
it('hides the warning when the status check rejects', async () => {
const getWindowsElevationStatus = installElevationStatusFailure();
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(WindowsAdministratorBanner));
await flushReact();
});
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
expect(host.textContent).toBe('');
await act(async () => {
root.unmount();
await flushReact();
});
});
it('does not render stale status after unmount', async () => {
let resolveStatus: ((status: WindowsElevationStatus) => void) | null = null;
const pendingStatus = new Promise<WindowsElevationStatus>((resolve) => {
resolveStatus = resolve;
});
const getWindowsElevationStatus = installElevationStatusPromise(pendingStatus);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(WindowsAdministratorBanner));
await flushReact();
});
await act(async () => {
root.unmount();
resolveStatus?.(createStatus());
await flushReact();
});
expect(getWindowsElevationStatus).toHaveBeenCalledTimes(1);
expect(host.textContent).toBe('');
});
it('hides the warning when the preload bridge does not expose the status check', async () => {
Object.defineProperty(window, 'electronAPI', {
configurable: true,
value: {},
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(WindowsAdministratorBanner));
await flushReact();
});
expect(host.textContent).toBe('');
await act(async () => {
root.unmount();
await flushReact();
});
});
});

View file

@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { AlertTriangle } from 'lucide-react';
import type { WindowsElevationStatus } from '@shared/types/api';
export const WindowsAdministratorBanner = (): React.JSX.Element | null => {
const [status, setStatus] = useState<WindowsElevationStatus | null>(null);
useEffect(() => {
if (!isElectronMode()) {
return undefined;
}
const getStatus = api.getWindowsElevationStatus;
if (typeof getStatus !== 'function') {
return undefined;
}
let cancelled = false;
void getStatus()
.then((nextStatus) => {
if (!cancelled) {
setStatus(nextStatus);
}
})
.catch(() => {
if (!cancelled) {
setStatus(null);
}
});
return () => {
cancelled = true;
};
}, []);
if (!status?.isWindows || status.isAdministrator !== false) {
return null;
}
return (
<div
className="mb-6 flex items-start gap-3 rounded-lg border px-4 py-3"
role="status"
style={{
borderColor: 'rgba(245, 158, 11, 0.35)',
backgroundColor: 'rgba(245, 158, 11, 0.07)',
}}
>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-amber-200">
Windows Administrator mode recommended
</div>
<p className="mt-1 text-xs leading-5" style={{ color: 'var(--color-text-secondary)' }}>
OpenCode runtime checks can time out when Agent Teams AI is not elevated. Restart the app
with Run as administrator before launching OpenCode teams.
</p>
</div>
</div>
);
};

View file

@ -206,10 +206,8 @@ export const PluginsPanel = ({
return ( return (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-300"> <div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-300">
In the multimodel runtime, plugins are currently guaranteed only for Anthropic Plugin support is currently guaranteed for Anthropic (Claude) sessions only.
sessions. We are actively building broader plugin support for all agents, including We&apos;re working to support plugins across all agents.
both universal plugins and agent-specific plugins.
{capability.reason ? ` ${capability.reason}` : ''}
</div> </div>
); );
})()} })()}

View file

@ -1082,6 +1082,7 @@ export const CreateTeamDialog = ({
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
backendSummary: plan.backendSummary, backendSummary: plan.backendSummary,
details: plan.cachedSnapshot.details, details: plan.cachedSnapshot.details,
supportDiagnostics: undefined,
}); });
prepareWarningsByProviderIdRef.current.delete(plan.providerId); prepareWarningsByProviderIdRef.current.delete(plan.providerId);
} }
@ -1150,6 +1151,7 @@ export const CreateTeamDialog = ({
status, status,
backendSummary: plan.backendSummary, backendSummary: plan.backendSummary,
details, details,
supportDiagnostics: undefined,
} }
); );
commitChecks(nextChecks); commitChecks(nextChecks);
@ -1181,6 +1183,7 @@ export const CreateTeamDialog = ({
status: prepResult.status, status: prepResult.status,
backendSummary: plan.backendSummary, backendSummary: plan.backendSummary,
details: prepResult.details, details: prepResult.details,
supportDiagnostics: prepResult.supportDiagnostics,
}); });
commitChecks(nextChecks); commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage); applyPrepareOutcome(nextChecks, loadingMessage);
@ -1194,6 +1197,7 @@ export const CreateTeamDialog = ({
status: 'failed', status: 'failed',
backendSummary: plan.backendSummary, backendSummary: plan.backendSummary,
details: [failureMessage], details: [failureMessage],
supportDiagnostics: undefined,
}); });
prepareWarningsByProviderIdRef.current.delete(plan.providerId); prepareWarningsByProviderIdRef.current.delete(plan.providerId);
commitChecks(nextChecks); commitChecks(nextChecks);

View file

@ -1674,6 +1674,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking', status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
backendSummary: plan.backendSummary, backendSummary: plan.backendSummary,
details: plan.cachedSnapshot.details, details: plan.cachedSnapshot.details,
supportDiagnostics: undefined,
}); });
prepareWarningsByProviderIdRef.current.delete(plan.providerId); prepareWarningsByProviderIdRef.current.delete(plan.providerId);
} }
@ -1722,6 +1723,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
status, status,
backendSummary: plan.backendSummary, backendSummary: plan.backendSummary,
details, details,
supportDiagnostics: undefined,
}); });
commitChecks(nextChecks); commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage); applyPrepareOutcome(nextChecks, loadingMessage);
@ -1752,6 +1754,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
status: prepResult.status, status: prepResult.status,
backendSummary: plan.backendSummary, backendSummary: plan.backendSummary,
details: prepResult.details, details: prepResult.details,
supportDiagnostics: prepResult.supportDiagnostics,
}); });
commitChecks(nextChecks); commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage); applyPrepareOutcome(nextChecks, loadingMessage);
@ -1765,6 +1768,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
status: 'failed', status: 'failed',
backendSummary: plan.backendSummary, backendSummary: plan.backendSummary,
details: [failureMessage], details: [failureMessage],
supportDiagnostics: undefined,
}); });
prepareWarningsByProviderIdRef.current.delete(plan.providerId); prepareWarningsByProviderIdRef.current.delete(plan.providerId);
commitChecks(nextChecks); commitChecks(nextChecks);

View file

@ -0,0 +1,152 @@
import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
import { describe, expect, it } from 'vitest';
import { getProvisioningFailureHint } from './ProvisioningProviderStatusList';
describe('getProvisioningFailureHint', () => {
it('returns the OpenCode Windows permissions hint for OpenCode access-denied details', () => {
expect(
getProvisioningFailureHint(null, [
{
providerId: 'opencode',
status: 'failed',
details: [OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE],
},
])
).toBe(
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
);
});
it('keeps non-OpenCode access-denied details on the generic CLI hint', () => {
expect(
getProvisioningFailureHint(null, [
{
providerId: 'anthropic',
status: 'failed',
details: ['EACCES: permission denied'],
},
])
).toBe(
'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
);
});
it('does not treat a mixed-provider generic access-denied message as OpenCode-specific', () => {
expect(
getProvisioningFailureHint('EACCES: permission denied', [
{
providerId: 'opencode',
status: 'ready',
details: [],
},
{
providerId: 'anthropic',
status: 'failed',
details: ['EACCES: permission denied'],
},
])
).toBe(
'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
);
});
it('does not prefer OpenCode access-denied notes over another provider failure', () => {
expect(
getProvisioningFailureHint(null, [
{
providerId: 'opencode',
status: 'notes',
details: [OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE],
},
{
providerId: 'anthropic',
status: 'failed',
details: ['EACCES: permission denied'],
},
])
).toBe(
'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
);
});
it('uses a normalized OpenCode access-denied message for a failed mixed-provider check', () => {
expect(
getProvisioningFailureHint(OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE, [
{
providerId: 'opencode',
status: 'failed',
details: [],
},
{
providerId: 'anthropic',
status: 'ready',
details: [],
},
])
).toBe(
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
);
});
it('uses a raw OpenCode access-denied message when no other provider failed', () => {
expect(
getProvisioningFailureHint('EPERM: operation not permitted', [
{
providerId: 'opencode',
status: 'failed',
details: [],
},
{
providerId: 'anthropic',
status: 'ready',
details: [],
},
])
).toBe(
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
);
});
it('uses the OpenCode Windows permissions hint for a single OpenCode access-denied message', () => {
expect(
getProvisioningFailureHint('EPERM: operation not permitted', [
{
providerId: 'opencode',
status: 'failed',
details: [],
},
])
).toBe(
'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
);
});
it('keeps existing OpenCode runtime missing and MCP hints unchanged', () => {
expect(
getProvisioningFailureHint(null, [
{
providerId: 'opencode',
status: 'failed',
details: [
'OpenCode runtime binary is not installed or not reachable by launch preflight.',
],
},
])
).toBe(
'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.'
);
expect(
getProvisioningFailureHint(null, [
{
providerId: 'opencode',
status: 'failed',
details: ['OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.'],
},
])
).toBe(
'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.'
);
});
});

View file

@ -2,9 +2,17 @@ import React from 'react';
import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity'; import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { AlertTriangle, CheckCircle2, Loader2, SlidersHorizontal } from 'lucide-react'; import {
isOpenCodeWindowsAccessDeniedDiagnostic,
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { AlertTriangle, Check, CheckCircle2, Copy, Loader2, SlidersHorizontal } from 'lucide-react';
import type { CliProviderStatus, TeamProviderId } from '@shared/types'; import type {
CliProviderStatus,
TeamProviderId,
TeamProvisioningSupportDiagnostic,
} from '@shared/types';
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed'; export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
export type ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed'; export type ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed';
@ -14,6 +22,7 @@ export interface ProvisioningProviderCheck {
status: ProvisioningProviderCheckStatus; status: ProvisioningProviderCheckStatus;
backendSummary?: string | null; backendSummary?: string | null;
details: string[]; details: string[];
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
} }
export function getProvisioningProviderLabel(providerId: TeamProviderId): string { export function getProvisioningProviderLabel(providerId: TeamProviderId): string {
@ -151,6 +160,8 @@ export function getProvisioningProviderProgressMessage(
type ProvisioningDetailSummary = type ProvisioningDetailSummary =
| 'CLI binary missing' | 'CLI binary missing'
| 'OpenCode runtime missing' | 'OpenCode runtime missing'
| 'OpenCode Windows access blocked'
| 'OpenCode runtime check returned no output'
| 'OpenCode app MCP unreachable' | 'OpenCode app MCP unreachable'
| 'Working directory missing' | 'Working directory missing'
| 'CLI binary could not be started' | 'CLI binary could not be started'
@ -174,6 +185,16 @@ function isSelectedModelDetail(lower: string): boolean {
return lower.includes('selected model'); return lower.includes('selected model');
} }
function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean {
const lower = value?.trim().toLowerCase() ?? '';
return (
lower.includes('opencode runtime check returned no output') ||
lower.includes('bridge stdout was empty') ||
lower.includes('opencode_bridge_contract_violation') ||
(lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation'))
);
}
function isFormattedModelDetail(lower: string): boolean { function isFormattedModelDetail(lower: string): boolean {
return ( return (
lower.includes(' - checking...') || lower.includes(' - checking...') ||
@ -219,10 +240,17 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
function summarizeDetail( function summarizeDetail(
detail: string, detail: string,
status: ProvisioningProviderCheckStatus status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): ProvisioningDetailSummary | null { ): ProvisioningDetailSummary | null {
const lower = detail.toLowerCase(); const lower = detail.toLowerCase();
if (providerId === 'opencode' && isOpenCodeWindowsAccessDeniedDiagnostic(detail)) {
return 'OpenCode Windows access blocked';
}
if (providerId === 'opencode' && isOpenCodeBridgeNoOutputDiagnostic(detail)) {
return 'OpenCode runtime check returned no output';
}
if (lower.includes('spawn ') && lower.includes(' enoent')) { if (lower.includes('spawn ') && lower.includes(' enoent')) {
return 'CLI binary missing'; return 'CLI binary missing';
} }
@ -449,13 +477,15 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
} }
const summarizedDetails = publicDetails const summarizedDetails = publicDetails
.map((detail) => summarizeDetail(detail, check.status)) .map((detail) => summarizeDetail(detail, check.status, check.providerId))
.filter((detail): detail is ProvisioningDetailSummary => Boolean(detail)); .filter((detail): detail is ProvisioningDetailSummary => Boolean(detail));
const summary = const summary =
check.status === 'failed' check.status === 'failed'
? (summarizedDetails.find( ? (summarizedDetails.find(
(detail) => (detail) =>
detail === 'OpenCode Windows access blocked' ||
detail === 'OpenCode runtime check returned no output' ||
detail === 'OpenCode app MCP unreachable' || detail === 'OpenCode app MCP unreachable' ||
detail === 'OpenCode runtime missing' || detail === 'OpenCode runtime missing' ||
detail === 'Selected model unavailable' || detail === 'Selected model unavailable' ||
@ -472,9 +502,10 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
function getDetailTone( function getDetailTone(
detail: string, detail: string,
status: ProvisioningProviderCheckStatus status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): 'success' | 'failure' | 'checking' | 'neutral' { ): 'success' | 'failure' | 'checking' | 'neutral' {
const summary = summarizeDetail(detail, status); const summary = summarizeDetail(detail, status, providerId);
if ( if (
summary === 'Selected model verified' || summary === 'Selected model verified' ||
summary === 'Selected model available' || summary === 'Selected model available' ||
@ -493,6 +524,8 @@ function getDetailTone(
summary === 'Selected model check failed' || summary === 'Selected model check failed' ||
summary === 'CLI binary missing' || summary === 'CLI binary missing' ||
summary === 'OpenCode runtime missing' || summary === 'OpenCode runtime missing' ||
summary === 'OpenCode Windows access blocked' ||
summary === 'OpenCode runtime check returned no output' ||
summary === 'OpenCode app MCP unreachable' || summary === 'OpenCode app MCP unreachable' ||
summary === 'Working directory missing' || summary === 'Working directory missing' ||
summary === 'CLI binary could not be started' || summary === 'CLI binary could not be started' ||
@ -510,8 +543,12 @@ function getDetailTone(
return 'neutral'; return 'neutral';
} }
function getDetailColorClass(detail: string, status: ProvisioningProviderCheckStatus): string { function getDetailColorClass(
switch (getDetailTone(detail, status)) { detail: string,
status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): string {
switch (getDetailTone(detail, status, providerId)) {
case 'success': case 'success':
return 'text-emerald-400'; return 'text-emerald-400';
case 'failure': case 'failure':
@ -551,14 +588,14 @@ export function getPrimaryProvisioningFailureDetail(
const publicDetails = getPublicProvisioningDetails(check.details); const publicDetails = getPublicProvisioningDetails(check.details);
const preferredFailure = publicDetails.find( const preferredFailure = publicDetails.find(
(detail) => getDetailTone(detail, check.status) === 'failure' (detail) => getDetailTone(detail, check.status, check.providerId) === 'failure'
); );
if (preferredFailure) { if (preferredFailure) {
return preferredFailure; return preferredFailure;
} }
const nonSuccessDetail = publicDetails.find( const nonSuccessDetail = publicDetails.find(
(detail) => getDetailTone(detail, check.status) !== 'success' (detail) => getDetailTone(detail, check.status, check.providerId) !== 'success'
); );
if (nonSuccessDetail) { if (nonSuccessDetail) {
return nonSuccessDetail; return nonSuccessDetail;
@ -715,6 +752,16 @@ function getProvisioningProviderSettingsActionLabel(
: null; : null;
} }
function getSupportDiagnosticsPayload(check: ProvisioningProviderCheck): string | null {
if (check.providerId !== 'opencode') {
return null;
}
const payloads = (check.supportDiagnostics ?? [])
.map((diagnostic) => diagnostic.copyText.trim())
.filter(Boolean);
return payloads.length > 0 ? payloads.join('\n\n---\n\n') : null;
}
export const ProvisioningProviderStatusList = ({ export const ProvisioningProviderStatusList = ({
checks, checks,
className = '', className = '',
@ -726,10 +773,29 @@ export const ProvisioningProviderStatusList = ({
suppressDetailsMatching?: string | null; suppressDetailsMatching?: string | null;
onOpenProviderSettings?: (providerId: TeamProviderId) => void; onOpenProviderSettings?: (providerId: TeamProviderId) => void;
}): React.JSX.Element | null => { }): React.JSX.Element | null => {
const [copiedDiagnosticsKey, setCopiedDiagnosticsKey] = React.useState<string | null>(null);
if (checks.length === 0) { if (checks.length === 0) {
return null; return null;
} }
const copySupportDiagnostics = async (copyKey: string, payload: string): Promise<void> => {
try {
const writeText = globalThis.navigator?.clipboard?.writeText;
if (typeof writeText !== 'function') {
setCopiedDiagnosticsKey(null);
return;
}
await writeText.call(globalThis.navigator.clipboard, payload);
setCopiedDiagnosticsKey(copyKey);
globalThis.setTimeout(() => {
setCopiedDiagnosticsKey((currentKey) => (currentKey === copyKey ? null : currentKey));
}, 1500);
} catch {
setCopiedDiagnosticsKey(null);
}
};
return ( return (
<div className={`space-y-1 pl-5 ${className}`.trim()}> <div className={`space-y-1 pl-5 ${className}`.trim()}>
{checks.map((check) => { {checks.map((check) => {
@ -740,6 +806,12 @@ export const ProvisioningProviderStatusList = ({
const settingsActionLabel = onOpenProviderSettings const settingsActionLabel = onOpenProviderSettings
? getProvisioningProviderSettingsActionLabel(check) ? getProvisioningProviderSettingsActionLabel(check)
: null; : null;
const supportDiagnosticsPayload = getSupportDiagnosticsPayload(check);
const supportDiagnosticsKey =
supportDiagnosticsPayload && check.supportDiagnostics?.[0]
? `${check.providerId}:${check.supportDiagnostics[0].id}`
: check.providerId;
const copiedDiagnostics = copiedDiagnosticsKey === supportDiagnosticsKey;
return ( return (
<div key={check.providerId}> <div key={check.providerId}>
@ -758,7 +830,11 @@ export const ProvisioningProviderStatusList = ({
{visibleDetails.map((detail, index) => ( {visibleDetails.map((detail, index) => (
<p <p
key={`${check.providerId}:${index}:${detail}`} key={`${check.providerId}:${index}:${detail}`}
className={`text-[10px] ${getDetailColorClass(detail, check.status)}`} className={`text-[10px] ${getDetailColorClass(
detail,
check.status,
check.providerId
)}`}
> >
{detail} {detail}
</p> </p>
@ -781,6 +857,24 @@ export const ProvisioningProviderStatusList = ({
</button> </button>
</div> </div>
) : null} ) : null}
{supportDiagnosticsPayload ? (
<div className="mt-1 pl-4">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
onClick={() =>
void copySupportDiagnostics(supportDiagnosticsKey, supportDiagnosticsPayload)
}
>
{copiedDiagnostics ? <Check className="size-3" /> : <Copy className="size-3" />}
{copiedDiagnostics ? 'Copied' : 'Copy diagnostics'}
</button>
</div>
) : null}
</div> </div>
); );
})} })}
@ -792,6 +886,34 @@ export function getProvisioningFailureHint(
message: string | null | undefined, message: string | null | undefined,
checks: ProvisioningProviderCheck[] checks: ProvisioningProviderCheck[]
): string { ): string {
const failedOpenCodeChecks = checks.filter(
(check) => check.providerId === 'opencode' && check.status === 'failed'
);
const hasFailedNonOpenCodeCheck = checks.some(
(check) => check.providerId !== 'opencode' && check.status === 'failed'
);
const hasOpenCodeAccessDeniedDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeWindowsAccessDeniedDiagnostic)
);
const hasOpenCodeBridgeNoOutputDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeBridgeNoOutputDiagnostic)
);
const normalizedMessage = message?.trim() ?? '';
const hasOpenCodeAccessDeniedMessage =
failedOpenCodeChecks.length > 0 &&
(normalizedMessage === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE ||
(!hasFailedNonOpenCodeCheck && isOpenCodeWindowsAccessDeniedDiagnostic(normalizedMessage)));
if (hasOpenCodeAccessDeniedDetail || hasOpenCodeAccessDeniedMessage) {
return 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.';
}
const hasOpenCodeBridgeNoOutputMessage =
failedOpenCodeChecks.length > 0 &&
!hasFailedNonOpenCodeCheck &&
isOpenCodeBridgeNoOutputDiagnostic(normalizedMessage);
if (hasOpenCodeBridgeNoOutputDetail || hasOpenCodeBridgeNoOutputMessage) {
return 'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.';
}
const combined = [message ?? '', ...checks.flatMap((check) => check.details)] const combined = [message ?? '', ...checks.flatMap((check) => check.details)]
.join('\n') .join('\n')
.toLowerCase(); .toLowerCase();

View file

@ -41,8 +41,8 @@ export const SkipPermissionsCheckbox: React.FC<SkipPermissionsCheckboxProps> = (
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-blue-400" /> <Info className="mt-0.5 size-3.5 shrink-0 text-blue-400" />
<p> <p>
Unleash Claude&apos;s full power no interruptions asking for permission. Autonomous Autonomous mode: team tools execute without confirmation. Be cautious with untrusted
mode all tools execute without confirmation. Be cautious with untrusted code. code.
</p> </p>
</div> </div>
</div> </div>
@ -57,7 +57,7 @@ export const SkipPermissionsCheckbox: React.FC<SkipPermissionsCheckboxProps> = (
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-blue-400" /> <Info className="mt-0.5 size-3.5 shrink-0 text-blue-400" />
<p>Manual mode you&apos;ll approve or deny each tool call in real-time.</p> <p>Manual mode: you&apos;ll approve or deny each tool call in real time.</p>
</div> </div>
</div> </div>
)} )}

View file

@ -104,6 +104,7 @@ interface OpenCodeModelOptionMetadata {
pricingInfo: OpenCodeModelPricingInfo | null; pricingInfo: OpenCodeModelPricingInfo | null;
searchText: string; searchText: string;
isRecommended: boolean; isRecommended: boolean;
isFree: boolean;
} }
interface OpenCodeVirtualHeadingRow { interface OpenCodeVirtualHeadingRow {
@ -220,6 +221,24 @@ function buildOpenCodeModelSearchText({
.toLowerCase(); .toLowerCase();
} }
function isFreeOpenCodeModelOption({
option,
routeMetadata,
pricingInfo,
}: {
option: TeamRuntimeModelOption;
routeMetadata: NonNullable<ProviderModelCatalogItem['metadata']>['opencode'] | null;
pricingInfo: OpenCodeModelPricingInfo | null;
}): boolean {
const badgeLabel = option.badgeLabel?.trim().toLowerCase();
return (
pricingInfo?.free === true ||
routeMetadata?.routeKind === 'builtin_free' ||
badgeLabel === 'free' ||
isFreeOpenCodeModelRoute(option.value)
);
}
function getOpenCodeModelGridColumnCount(width: number): number { function getOpenCodeModelGridColumnCount(width: number): number {
const safeWidth = Number.isFinite(width) ? Math.max(0, width) : 0; const safeWidth = Number.isFinite(width) ? Math.max(0, width) : 0;
if (safeWidth <= 0) { if (safeWidth <= 0) {
@ -721,6 +740,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}) => { }) => {
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const [recommendedOnly, setRecommendedOnly] = useState(false); const [recommendedOnly, setRecommendedOnly] = useState(false);
const [freeOnly, setFreeOnly] = useState(false);
const [modelQuery, setModelQuery] = useState(''); const [modelQuery, setModelQuery] = useState('');
const [openCodeSourceFilterOpen, setOpenCodeSourceFilterOpen] = useState(false); const [openCodeSourceFilterOpen, setOpenCodeSourceFilterOpen] = useState(false);
const [openCodeSourceQuery, setOpenCodeSourceQuery] = useState(''); const [openCodeSourceQuery, setOpenCodeSourceQuery] = useState('');
@ -958,6 +978,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
pricingInfo, pricingInfo,
}), }),
isRecommended: isRecommendedTeamModelRecommendation(recommendation), isRecommended: isRecommendedTeamModelRecommendation(recommendation),
isFree: isFreeOpenCodeModelOption({ option, routeMetadata, pricingInfo }),
}; };
}); });
}, [effectiveProviderId, modelOptions, openCodeCatalogModelById]); }, [effectiveProviderId, modelOptions, openCodeCatalogModelById]);
@ -969,6 +990,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
() => openCodeModelMetadata.some((metadata) => metadata.isRecommended), () => openCodeModelMetadata.some((metadata) => metadata.isRecommended),
[openCodeModelMetadata] [openCodeModelMetadata]
); );
const hasFreeOpenCodeModels = useMemo(
() => openCodeModelMetadata.some((metadata) => metadata.isFree),
[openCodeModelMetadata]
);
useEffect(() => { useEffect(() => {
if (previousSelectedProviderIdRef.current === selectedProviderId) { if (previousSelectedProviderIdRef.current === selectedProviderId) {
@ -984,6 +1009,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
} }
}, [effectiveProviderId, hasRecommendedOpenCodeModels, recommendedOnly]); }, [effectiveProviderId, hasRecommendedOpenCodeModels, recommendedOnly]);
useEffect(() => {
if (freeOnly && (effectiveProviderId !== 'opencode' || !hasFreeOpenCodeModels)) {
setFreeOnly(false);
}
}, [effectiveProviderId, freeOnly, hasFreeOpenCodeModels]);
useEffect(() => { useEffect(() => {
if (previousEffectiveProviderIdRef.current === effectiveProviderId) { if (previousEffectiveProviderIdRef.current === effectiveProviderId) {
return; return;
@ -1023,6 +1054,9 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
if (recommendedOnly && !metadata.isRecommended) { if (recommendedOnly && !metadata.isRecommended) {
continue; continue;
} }
if (freeOnly && !metadata.isFree) {
continue;
}
const sourceInfo = metadata.sourceInfo; const sourceInfo = metadata.sourceInfo;
if (!sourceInfo) { if (!sourceInfo) {
@ -1040,7 +1074,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return Array.from(sourceOptions.values()).sort((left, right) => return Array.from(sourceOptions.values()).sort((left, right) =>
left.label.localeCompare(right.label, undefined, { sensitivity: 'base' }) left.label.localeCompare(right.label, undefined, { sensitivity: 'base' })
); );
}, [effectiveProviderId, openCodeModelMetadata, recommendedOnly]); }, [effectiveProviderId, freeOnly, openCodeModelMetadata, recommendedOnly]);
useEffect(() => { useEffect(() => {
if (selectedOpenCodeSourceIds.size === 0) { if (selectedOpenCodeSourceIds.size === 0) {
@ -1105,6 +1139,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const concreteOptions = openCodeModelMetadata const concreteOptions = openCodeModelMetadata
.filter((metadata) => metadata.option.value.trim().length > 0) .filter((metadata) => metadata.option.value.trim().length > 0)
.filter((metadata) => !recommendedOnly || metadata.isRecommended) .filter((metadata) => !recommendedOnly || metadata.isRecommended)
.filter((metadata) => !freeOnly || metadata.isFree)
.filter((metadata) => { .filter((metadata) => {
if (selectedOpenCodeSourceIds.size === 0) { if (selectedOpenCodeSourceIds.size === 0) {
return true; return true;
@ -1123,7 +1158,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return recommendationOrder || left.index - right.index; return recommendationOrder || left.index - right.index;
}); });
if (recommendedOnly) { if (recommendedOnly || freeOnly) {
return concreteOptions; return concreteOptions;
} }
@ -1135,6 +1170,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
]; ];
}, [ }, [
effectiveProviderId, effectiveProviderId,
freeOnly,
modelQuery, modelQuery,
openCodeModelMetadata, openCodeModelMetadata,
recommendedOnly, recommendedOnly,
@ -1220,6 +1256,15 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
effectiveProviderId === 'opencode' && effectiveProviderId === 'opencode' &&
!shouldShowOpenCodeCatalogLoading && !shouldShowOpenCodeCatalogLoading &&
visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD; visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD;
const emptyModelListMessage = trimmedModelQuery
? 'No models match this search.'
: effectiveProviderId === 'opencode' && recommendedOnly && freeOnly
? 'No recommended free OpenCode models are available in the current runtime list.'
: effectiveProviderId === 'opencode' && freeOnly
? 'No free OpenCode models are available in the current runtime list.'
: effectiveProviderId === 'opencode' && recommendedOnly
? 'No recommended OpenCode models are available in the current runtime list.'
: 'No models are available in the current runtime list.';
const activeProviderDisabledReason = activeProviderSelectable const activeProviderDisabledReason = activeProviderSelectable
? null ? null
: getProviderDisabledReason(effectiveProviderId); : getProviderDisabledReason(effectiveProviderId);
@ -1689,7 +1734,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
) : null} ) : null}
{!shouldShowOpenCodeCatalogLoading && {!shouldShowOpenCodeCatalogLoading &&
((effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) || ((effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) ||
hasRecommendedOpenCodeModels) ? ( hasRecommendedOpenCodeModels ||
hasFreeOpenCodeModels) ? (
<div className="mb-2 flex flex-wrap items-center gap-2"> <div className="mb-2 flex flex-wrap items-center gap-2">
{effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? ( {effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? (
<Popover <Popover
@ -1785,6 +1831,22 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</Label> </Label>
</div> </div>
) : null} ) : null}
{hasFreeOpenCodeModels ? (
<div className="flex w-fit items-center gap-2">
<Checkbox
id="opencode-team-model-free-only"
checked={freeOnly}
onCheckedChange={(checked) => setFreeOnly(checked === true)}
className="size-3.5"
/>
<Label
htmlFor="opencode-team-model-free-only"
className="cursor-pointer text-[11px] font-normal text-[var(--color-text-secondary)]"
>
Free only
</Label>
</div>
) : null}
</div> </div>
) : null} ) : null}
{effectiveProviderId === 'opencode' ? ( {effectiveProviderId === 'opencode' ? (
@ -1869,11 +1931,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
)} )}
{visibleModelOptions.length === 0 && !shouldShowOpenCodeCatalogLoading ? ( {visibleModelOptions.length === 0 && !shouldShowOpenCodeCatalogLoading ? (
<div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]"> <div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]">
{trimmedModelQuery {emptyModelListMessage}
? 'No models match this search.'
: effectiveProviderId === 'opencode' && recommendedOnly
? 'No recommended OpenCode models are available in the current runtime list.'
: 'No models are available in the current runtime list.'}
</div> </div>
) : null} ) : null}
</div> </div>

View file

@ -0,0 +1,75 @@
import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
import { describe, expect, it } from 'vitest';
import { runProviderPrepareDiagnostics } from './providerPrepareDiagnostics';
import type { TeamProvisioningPrepareResult } from '@shared/types';
describe('runProviderPrepareDiagnostics', () => {
it('normalizes OpenCode access-denied provider failures', async () => {
const result = await runProviderPrepareDiagnostics({
cwd: 'C:\\Program Files\\locked-project',
providerId: 'opencode',
selectedModelIds: [],
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
ready: false,
message: 'OpenCode bridge failed: EPERM: operation not permitted, mkdir C:\\Program Files',
}),
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
});
it('keeps non-OpenCode access-denied provider failures generic', async () => {
const detail = 'EACCES: permission denied, open C:\\work\\repo';
const result = await runProviderPrepareDiagnostics({
cwd: 'C:\\work\\repo',
providerId: 'anthropic',
selectedModelIds: [],
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
ready: false,
message: detail,
}),
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([detail]);
});
it('normalizes OpenCode access-denied runtime note details', async () => {
const result = await runProviderPrepareDiagnostics({
cwd: 'C:\\Program Files\\locked-project',
providerId: 'opencode',
selectedModelIds: [],
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
ready: true,
message: '',
warnings: ['EACCES: permission denied, open C:\\Program Files\\locked-project'],
}),
});
expect(result.status).toBe('notes');
expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
expect(result.warnings).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
});
it('treats model-scoped OpenCode access-denied details as provider failures', async () => {
const result = await runProviderPrepareDiagnostics({
cwd: 'C:\\Program Files\\locked-project',
providerId: 'opencode',
selectedModelIds: ['opencode/big-pickle'],
prepareProvisioning: async (): Promise<TeamProvisioningPrepareResult> => ({
ready: false,
message: 'Selected model opencode/big-pickle is unavailable.',
details: [
'Selected model opencode/big-pickle is unavailable. EPERM: operation not permitted',
],
}),
});
expect(result.status).toBe('failed');
expect(result.details).toEqual([OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE]);
expect(result.modelResultsById).toEqual({});
});
});

View file

@ -1,4 +1,8 @@
import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog'; import { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
import {
isOpenCodeWindowsAccessDeniedDiagnostic,
normalizeOpenCodeWindowsAccessDeniedDiagnostic,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection'; import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
import type { import type {
@ -6,6 +10,7 @@ import type {
TeamProvisioningModelCheckRequest, TeamProvisioningModelCheckRequest,
TeamProvisioningModelVerificationMode, TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareResult, TeamProvisioningPrepareResult,
TeamProvisioningSupportDiagnostic,
} from '@shared/types'; } from '@shared/types';
export type ProviderPrepareCheckStatus = 'ready' | 'notes' | 'failed'; export type ProviderPrepareCheckStatus = 'ready' | 'notes' | 'failed';
@ -45,6 +50,7 @@ export interface ProviderPrepareDiagnosticsResult {
details: string[]; details: string[];
warnings: string[]; warnings: string[];
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>; modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
} }
type TeamProvisioningPrepareIssue = NonNullable<TeamProvisioningPrepareResult['issues']>[number]; type TeamProvisioningPrepareIssue = NonNullable<TeamProvisioningPrepareResult['issues']>[number];
@ -93,6 +99,45 @@ function uniquePrepareLines(lines: (string | null | undefined)[]): string[] {
return uniqueLines; return uniqueLines;
} }
function isOpenCodeBridgeNoOutputDiagnostic(value: string | null | undefined): boolean {
const lower = value?.trim().toLowerCase() ?? '';
return (
lower.includes('opencode runtime check returned no output') ||
lower.includes('bridge stdout was empty') ||
lower.includes('opencode_bridge_contract_violation') ||
(lower.includes('opencode readiness bridge failed') && lower.includes('contract_violation'))
);
}
function cloneSupportDiagnostics(
diagnostics: readonly TeamProvisioningSupportDiagnostic[] | undefined
): TeamProvisioningSupportDiagnostic[] {
return (diagnostics ?? []).map((diagnostic) => ({ ...diagnostic }));
}
function mergeSupportDiagnostics(
target: TeamProvisioningSupportDiagnostic[],
incoming: readonly TeamProvisioningSupportDiagnostic[] | undefined
): void {
for (const diagnostic of incoming ?? []) {
if (!target.some((existing) => existing.id === diagnostic.id)) {
target.push({ ...diagnostic });
}
}
}
function withSupportDiagnostics(
result: ProviderPrepareDiagnosticsResult,
supportDiagnostics: readonly TeamProvisioningSupportDiagnostic[]
): ProviderPrepareDiagnosticsResult {
return supportDiagnostics.length > 0
? {
...result,
supportDiagnostics: cloneSupportDiagnostics(supportDiagnostics),
}
: result;
}
function getModelLabel(providerId: TeamProviderId, modelId: string): string { function getModelLabel(providerId: TeamProviderId, modelId: string): string {
if (isDefaultProviderModelSelection(modelId)) { if (isDefaultProviderModelSelection(modelId)) {
return 'Default'; return 'Default';
@ -287,8 +332,12 @@ function looksLikeOpenCodeRuntimeFailureReason(reason: string | null | undefined
} }
return ( return (
isOpenCodeBridgeNoOutputDiagnostic(reason) ||
isOpenCodeWindowsAccessDeniedDiagnostic(reason) ||
lower.includes('opencode /experimental/tool') || lower.includes('opencode /experimental/tool') ||
lower.includes('/experimental/tool') || lower.includes('/experimental/tool') ||
lower.includes('opencode_bridge_contract_violation') ||
lower.includes('bridge stdout was empty') ||
lower.includes('mcp_unavailable') || lower.includes('mcp_unavailable') ||
lower.includes('unable to connect') || lower.includes('unable to connect') ||
lower.includes('runtime store') || lower.includes('runtime store') ||
@ -339,7 +388,9 @@ function isAdvisoryOpenCodeDeepVerificationIssue(
lower.includes('/experimental/tool') || lower.includes('/experimental/tool') ||
lower.includes('runtime store') || lower.includes('runtime store') ||
lower.includes('opencode cli') || lower.includes('opencode cli') ||
lower.includes('opencode runtime binary'); lower.includes('opencode runtime binary') ||
isOpenCodeBridgeNoOutputDiagnostic(lower) ||
isOpenCodeWindowsAccessDeniedDiagnostic(lower);
if (hasHardRuntimeMarker) { if (hasHardRuntimeMarker) {
return false; return false;
} }
@ -476,27 +527,48 @@ function buildModelVerificationDeferredLine(
: `${label} - verification deferred`; : `${label} - verification deferred`;
} }
function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] { function createRuntimeDetailLines(
return uniquePrepareLines([...(result.details ?? []), ...(result.warnings ?? [])]); result: TeamProvisioningPrepareResult,
providerId: TeamProviderId
): string[] {
return uniquePrepareLines(
[...(result.details ?? []), ...(result.warnings ?? [])]
.map((detail) => normalizeRuntimeDetailLine(detail, providerId))
.filter(Boolean)
);
} }
function createRuntimeWarningLines(result: TeamProvisioningPrepareResult): string[] { function createRuntimeWarningLines(
result: TeamProvisioningPrepareResult,
providerId: TeamProviderId
): string[] {
return uniquePrepareLines( return uniquePrepareLines(
(result.warnings ?? []) (result.warnings ?? [])
.map((warning) => normalizeRuntimeFailureDetailLine(warning)) .map((warning) => normalizeRuntimeFailureDetailLine(warning, undefined, providerId))
.filter(Boolean) .filter(Boolean)
); );
} }
function normalizeRuntimeFailureDetailLine( function normalizeRuntimeFailureDetailLine(
detail: string | null | undefined, detail: string | null | undefined,
code?: string | null code?: string | null,
providerId?: TeamProviderId
): string | null { ): string | null {
const trimmed = detail?.trim(); const trimmed = detail?.trim();
if (!trimmed) { if (!trimmed) {
return null; return null;
} }
if (providerId === 'opencode') {
if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) {
return 'OpenCode runtime check returned no output.';
}
const accessDeniedDiagnostic = normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed);
if (accessDeniedDiagnostic) {
return accessDeniedDiagnostic;
}
}
if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) { if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) {
return 'OpenCode runtime binary is not installed or not reachable by launch preflight.'; return 'OpenCode runtime binary is not installed or not reachable by launch preflight.';
} }
@ -518,13 +590,33 @@ function normalizeRuntimeFailureDetailLine(
return trimmed; return trimmed;
} }
function normalizeRuntimeDetailLine(
detail: string | null | undefined,
providerId: TeamProviderId
): string | null {
const trimmed = detail?.trim();
if (!trimmed) {
return null;
}
if (providerId !== 'opencode') {
return trimmed;
}
if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) {
return 'OpenCode runtime check returned no output.';
}
return normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed) ?? trimmed;
}
function createRuntimeFailureDetailLines( function createRuntimeFailureDetailLines(
runtimeDetailLines: readonly string[], runtimeDetailLines: readonly string[],
message: string | null | undefined message: string | null | undefined,
providerId: TeamProviderId
): string[] { ): string[] {
return uniquePrepareLines( return uniquePrepareLines(
[...runtimeDetailLines, message] [...runtimeDetailLines, message]
.map((detail) => normalizeRuntimeFailureDetailLine(detail)) .map((detail) => normalizeRuntimeFailureDetailLine(detail, undefined, providerId))
.filter(Boolean) .filter(Boolean)
); );
} }
@ -957,6 +1049,7 @@ export async function runProviderPrepareDiagnostics({
); );
const hasExplicitModelChecks = (selectedModelChecks?.length ?? 0) > 0; const hasExplicitModelChecks = (selectedModelChecks?.length ?? 0) > 0;
const orderedModelIds = Array.from(new Set(normalizedModelChecks.map((check) => check.model))); const orderedModelIds = Array.from(new Set(normalizedModelChecks.map((check) => check.model)));
const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
if (orderedModelIds.length === 0) { if (orderedModelIds.length === 0) {
const runtimeResult = await prepareProvisioning( const runtimeResult = await prepareProvisioning(
cwd, cwd,
@ -965,24 +1058,35 @@ export async function runProviderPrepareDiagnostics({
undefined, undefined,
limitContext limitContext
); );
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult); mergeSupportDiagnostics(supportDiagnostics, runtimeResult.supportDiagnostics);
const runtimeWarnings = createRuntimeWarningLines(runtimeResult); const runtimeDetailLines = createRuntimeDetailLines(runtimeResult, providerId);
const runtimeWarnings = createRuntimeWarningLines(runtimeResult, providerId);
if (!runtimeResult.ready) { if (!runtimeResult.ready) {
return { return withSupportDiagnostics(
status: 'failed', {
details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message), status: 'failed',
warnings: runtimeWarnings, details: createRuntimeFailureDetailLines(
modelResultsById: {}, runtimeDetailLines,
}; runtimeResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
},
supportDiagnostics
);
} }
return { return withSupportDiagnostics(
status: runtimeWarnings.length > 0 ? 'notes' : 'ready', {
details: runtimeDetailLines, status: runtimeWarnings.length > 0 ? 'notes' : 'ready',
warnings: runtimeWarnings, details: runtimeDetailLines,
modelResultsById: {}, warnings: runtimeWarnings,
}; modelResultsById: {},
},
supportDiagnostics
);
} }
const reusableModelResultsById = cachedModelResultsById ?? {}; const reusableModelResultsById = cachedModelResultsById ?? {};
@ -1047,16 +1151,24 @@ export async function runProviderPrepareDiagnostics({
undefined, undefined,
limitContext limitContext
); );
runtimeDetailLines = createRuntimeDetailLines(runtimeResult); mergeSupportDiagnostics(supportDiagnostics, runtimeResult.supportDiagnostics);
runtimeWarnings = createRuntimeWarningLines(runtimeResult); runtimeDetailLines = createRuntimeDetailLines(runtimeResult, providerId);
runtimeWarnings = createRuntimeWarningLines(runtimeResult, providerId);
if (!runtimeResult.ready) { if (!runtimeResult.ready) {
return { return withSupportDiagnostics(
status: 'failed', {
details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message), status: 'failed',
warnings: runtimeWarnings, details: createRuntimeFailureDetailLines(
modelResultsById: {}, runtimeDetailLines,
}; runtimeResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
},
supportDiagnostics
);
} }
} else { } else {
const recordTerminalModelResult = ( const recordTerminalModelResult = (
@ -1090,10 +1202,11 @@ export async function runProviderPrepareDiagnostics({
? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)] ? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)]
: []) : [])
); );
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter( mergeSupportDiagnostics(supportDiagnostics, compatibilityResult.supportDiagnostics);
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter( runtimeWarnings = createRuntimeWarningLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
@ -1116,17 +1229,21 @@ export async function runProviderPrepareDiagnostics({
const structuredProviderScopedFailure = const structuredProviderScopedFailure =
structuredProviderScopedIssue?.message.trim() ?? null; structuredProviderScopedIssue?.message.trim() ?? null;
if (structuredProviderScopedFailure || providerScopedFailure) { if (structuredProviderScopedFailure || providerScopedFailure) {
return { return withSupportDiagnostics(
status: 'failed', {
details: [ status: 'failed',
normalizeRuntimeFailureDetailLine( details: [
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed', normalizeRuntimeFailureDetailLine(
structuredProviderScopedIssue?.code structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
) ?? 'OpenCode failed', structuredProviderScopedIssue?.code,
], providerId
warnings: [], ) ?? 'OpenCode failed',
modelResultsById: {}, ],
}; warnings: [],
modelResultsById: {},
},
supportDiagnostics
);
} }
if ( if (
shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({ shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
@ -1141,15 +1258,19 @@ export async function runProviderPrepareDiagnostics({
(uncachedModelIds.length > 1 || (uncachedModelIds.length > 1 ||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))) (!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)))
) { ) {
return { return withSupportDiagnostics(
status: 'failed', {
details: createRuntimeFailureDetailLines( status: 'failed',
runtimeDetailLines, details: createRuntimeFailureDetailLines(
compatibilityResult.message runtimeDetailLines,
), compatibilityResult.message,
warnings: runtimeWarnings, providerId
modelResultsById: {}, ),
}; warnings: runtimeWarnings,
modelResultsById: {},
},
supportDiagnostics
);
} }
if (!hasModelScopedEntries && uncachedModelIds.length === 1) { if (!hasModelScopedEntries && uncachedModelIds.length === 1) {
runtimeDetailLines = []; runtimeDetailLines = [];
@ -1204,19 +1325,22 @@ export async function runProviderPrepareDiagnostics({
) )
); );
return { return withSupportDiagnostics(
status: hasFailure {
? 'failed' status: hasFailure
: hasNotes || dedupedWarnings.length > 0 ? 'failed'
? 'notes' : hasNotes || dedupedWarnings.length > 0
: 'ready', ? 'notes'
details: [ : 'ready',
...filteredRuntime.runtimeDetailLines, details: [
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), ...filteredRuntime.runtimeDetailLines,
], ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
warnings: dedupedWarnings, ],
modelResultsById: selectedModelResultsById, warnings: dedupedWarnings,
}; modelResultsById: selectedModelResultsById,
},
supportDiagnostics
);
} }
try { try {
@ -1231,10 +1355,11 @@ export async function runProviderPrepareDiagnostics({
? [selectModelChecksForIds(normalizedModelChecks, compatibilityPassedModelIds)] ? [selectModelChecksForIds(normalizedModelChecks, compatibilityPassedModelIds)]
: []) : [])
); );
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter( mergeSupportDiagnostics(supportDiagnostics, batchedModelResult.supportDiagnostics);
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
); );
runtimeWarnings = createRuntimeWarningLines(batchedModelResult).filter( runtimeWarnings = createRuntimeWarningLines(batchedModelResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
); );
@ -1278,17 +1403,21 @@ export async function runProviderPrepareDiagnostics({
} }
handledAdvisoryDeepFailure = true; handledAdvisoryDeepFailure = true;
} else { } else {
return { return withSupportDiagnostics(
status: 'failed', {
details: [ status: 'failed',
normalizeRuntimeFailureDetailLine( details: [
failureReason, normalizeRuntimeFailureDetailLine(
structuredProviderScopedIssue?.code failureReason,
) ?? failureReason, structuredProviderScopedIssue?.code,
], providerId
warnings: [], ) ?? failureReason,
modelResultsById: {}, ],
}; warnings: [],
modelResultsById: {},
},
supportDiagnostics
);
} }
} }
if ( if (
@ -1323,15 +1452,19 @@ export async function runProviderPrepareDiagnostics({
(compatibilityPassedModelIds.length > 1 || (compatibilityPassedModelIds.length > 1 ||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)))) (!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))))
) { ) {
return { return withSupportDiagnostics(
status: 'failed', {
details: createRuntimeFailureDetailLines( status: 'failed',
runtimeDetailLines, details: createRuntimeFailureDetailLines(
batchedModelResult.message runtimeDetailLines,
), batchedModelResult.message,
warnings: runtimeWarnings, providerId
modelResultsById: {}, ),
}; warnings: runtimeWarnings,
modelResultsById: {},
},
supportDiagnostics
);
} }
if ( if (
!handledAdvisoryDeepFailure && !handledAdvisoryDeepFailure &&
@ -1385,10 +1518,10 @@ export async function runProviderPrepareDiagnostics({
? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)] ? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)]
: []) : [])
); );
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter( runtimeDetailLines = createRuntimeDetailLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter( runtimeWarnings = createRuntimeWarningLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
@ -1410,7 +1543,8 @@ export async function runProviderPrepareDiagnostics({
status: 'failed', status: 'failed',
details: createRuntimeFailureDetailLines( details: createRuntimeFailureDetailLines(
runtimeDetailLines, runtimeDetailLines,
compatibilityResult.message compatibilityResult.message,
providerId
), ),
warnings: runtimeWarnings, warnings: runtimeWarnings,
modelResultsById: {}, modelResultsById: {},
@ -1445,10 +1579,10 @@ export async function runProviderPrepareDiagnostics({
limitContext, limitContext,
'deep' 'deep'
); );
runtimeDetailLines = createRuntimeDetailLines(deepResult).filter( runtimeDetailLines = createRuntimeDetailLines(deepResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
runtimeWarnings = createRuntimeWarningLines(deepResult).filter( runtimeWarnings = createRuntimeWarningLines(deepResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry) (entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
); );
if ( if (
@ -1502,13 +1636,16 @@ export async function runProviderPrepareDiagnostics({
) )
); );
return { return withSupportDiagnostics(
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready', {
details: [ status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
...filteredRuntime.runtimeDetailLines, details: [
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), ...filteredRuntime.runtimeDetailLines,
], ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
warnings: dedupedWarnings, ],
modelResultsById: selectedModelResultsById, warnings: dedupedWarnings,
}; modelResultsById: selectedModelResultsById,
},
supportDiagnostics
);
} }

View file

@ -1043,7 +1043,7 @@ export const MemberCard = memo(function MemberCard({
<MemberRuntimeTelemetryStrip runtimeEntry={runtimeEntry} scale={runtimeTelemetryScale} /> <MemberRuntimeTelemetryStrip runtimeEntry={runtimeEntry} scale={runtimeTelemetryScale} />
) : null} ) : null}
<div className="pointer-events-none absolute inset-0 z-10 rounded transition-colors group-hover:bg-white/5" /> <div className="pointer-events-none absolute inset-0 z-10 rounded transition-colors group-hover:bg-white/5" />
<div className="relative z-20 flex items-center gap-2.5"> <div className="relative z-20 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 gap-y-1">
<div className="relative shrink-0"> <div className="relative shrink-0">
<div <div
className="rounded-full border-2 p-px" className="rounded-full border-2 p-px"
@ -1166,6 +1166,7 @@ export const MemberCard = memo(function MemberCard({
<MemberLaunchDiagnosticsButton <MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload} payload={launchDiagnosticsPayload}
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200" className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
attention
/> />
) : null} ) : null}
</> </>
@ -1201,336 +1202,348 @@ export const MemberCard = memo(function MemberCard({
) : null} ) : null}
</div> </div>
) : null} ) : null}
{launchFailureReason ? ( </div>
<div <div className="flex shrink-0 items-center gap-2.5 justify-self-end">
data-testid="member-launch-failure-reason" {showLaunchBadge ? (
className="mt-1 min-w-0 whitespace-pre-wrap break-words text-[10px] font-medium leading-snug text-red-300/90" <span
title={rawLaunchFailureReason} className="flex shrink-0 items-center gap-1"
title={runtimeEntry?.runtimeDiagnostic}
> >
<span> {launchVisualState === 'starting_stale' ? (
{renderLinkifiedText(launchFailureReason, { <AlertTriangle
linkClassName: 'underline underline-offset-2 hover:text-red-200', className="size-3.5 shrink-0 text-amber-400"
stopPropagation: true, aria-label={launchBadgeLabel}
getLinkLabel: getLaunchFailureLinkLabel, />
})} ) : (
</span> <SyncedLoader2
className="size-3.5 shrink-0 text-[var(--color-text-muted)]"
aria-label={launchBadgeLabel}
/>
)}
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
>
{launchBadgeLabel}
</Badge>
{canRelaunchOpenCode ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
}
className="rounded p-1 text-amber-300 transition-colors hover:bg-amber-500/10 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
</span>
) : showFailedLaunchBadge ? (
<span className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
<Badge
variant="secondary"
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
>
{displayPresenceLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
</Tooltip>
{showCopyDiagnostics ? (
<MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload}
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
attention
/>
) : null}
{canSkipFailedLaunch ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={skippingLaunch ? 'Skipping teammate' : 'Skip for this launch'}
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={skippingLaunch || retryingLaunch}
onClick={handleSkipFailedLaunch}
>
{skippingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<Ban className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{skipLaunchError ??
(skippingLaunch ? 'Skipping teammate...' : 'Skip for this launch')}
</TooltipContent>
</Tooltip>
) : null}
{canRetryLaunch ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
}
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch || skippingLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
</span>
) : showSkippedLaunchBadge ? (
<span className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<Ban className="size-3.5 shrink-0 text-zinc-400" />
<Badge
variant="secondary"
className="shrink-0 bg-zinc-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-zinc-300"
>
{displayPresenceLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{spawnEntry?.skipReason ?? 'Skipped for this launch'}
</TooltipContent>
</Tooltip>
{canRetryLaunch ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
}
className="rounded p-1 text-zinc-300 transition-colors hover:bg-zinc-500/10 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
</span>
) : showRuntimeAdvisoryBadge ? (
<span className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<AlertTriangle
className={`size-3.5 shrink-0 ${
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
}`}
/>
<Badge
variant="secondary"
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
runtimeAdvisoryTone === 'error'
? 'bg-red-500/15 text-red-300'
: 'bg-amber-500/15 text-amber-300'
}`}
title={runtimeAdvisoryTitle}
>
{runtimeAdvisoryLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
</TooltipContent>
</Tooltip>
{canRelaunchRuntimeAdvisoryOpenCode ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
}
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
{showRuntimeAdvisoryDiagnostics ? (
<MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload}
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
attention
/>
) : null}
</span>
) : !activityTask ? (
<Badge
variant="secondary"
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
title={isRemoved ? 'This member has been removed' : activityTitle}
>
{isRemoved ? 'removed' : displayPresenceLabel}
</Badge>
) : null}
{showStartingSkeleton ? (
<div className="shrink-0" aria-hidden="true">
<div
className="skeleton-shimmer h-[18px] w-[62px] rounded-full border"
style={{
backgroundColor: 'var(--skeleton-base-dim)',
borderColor: 'var(--color-border)',
}}
/>
<div
className="skeleton-shimmer mx-1 mt-1 h-[2px] w-10 rounded-full"
style={{ backgroundColor: 'var(--skeleton-base)' }}
/>
</div> </div>
) : (
<div
className="shrink-0"
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
>
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
>
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
</Badge>
{totalTasks > 0 && (
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
{/* NOTE: lead context bar disabled — usage formula is inaccurate */}
</div>
)}
{!isRemoved && (
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
onSendMessage?.();
}}
>
<MessageSquare size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Send message</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
onAssignTask?.();
}}
>
<Plus size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Assign task</TooltipContent>
</Tooltip>
</div>
)}
{canRestoreMember ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={restoringMember ? 'Restoring teammate' : 'Restore teammate'}
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-60"
disabled={restoringMember}
onClick={handleRestoreMember}
>
{restoringMember ? (
<SyncedLoader2 className="size-3.5" />
) : (
<Undo2 className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{restoreMemberError ?? (restoringMember ? 'Restoring teammate...' : 'Restore')}
</TooltipContent>
</Tooltip>
) : null} ) : null}
</div> </div>
{showLaunchBadge ? ( {launchFailureReason ? (
<span
className="flex shrink-0 items-center gap-1"
title={runtimeEntry?.runtimeDiagnostic}
>
{launchVisualState === 'starting_stale' ? (
<AlertTriangle
className="size-3.5 shrink-0 text-amber-400"
aria-label={launchBadgeLabel}
/>
) : (
<SyncedLoader2
className="size-3.5 shrink-0 text-[var(--color-text-muted)]"
aria-label={launchBadgeLabel}
/>
)}
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
>
{launchBadgeLabel}
</Badge>
{canRelaunchOpenCode ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
className="rounded p-1 text-amber-300 transition-colors hover:bg-amber-500/10 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
</span>
) : showFailedLaunchBadge ? (
<span className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
<Badge
variant="secondary"
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
>
{displayPresenceLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
</Tooltip>
{showCopyDiagnostics ? (
<MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload}
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
/>
) : null}
{canSkipFailedLaunch ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={skippingLaunch ? 'Skipping teammate' : 'Skip for this launch'}
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={skippingLaunch || retryingLaunch}
onClick={handleSkipFailedLaunch}
>
{skippingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<Ban className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{skipLaunchError ??
(skippingLaunch ? 'Skipping teammate...' : 'Skip for this launch')}
</TooltipContent>
</Tooltip>
) : null}
{canRetryLaunch ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch || skippingLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
</span>
) : showSkippedLaunchBadge ? (
<span className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<Ban className="size-3.5 shrink-0 text-zinc-400" />
<Badge
variant="secondary"
className="shrink-0 bg-zinc-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-zinc-300"
>
{displayPresenceLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{spawnEntry?.skipReason ?? 'Skipped for this launch'}
</TooltipContent>
</Tooltip>
{canRetryLaunch ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
className="rounded p-1 text-zinc-300 transition-colors hover:bg-zinc-500/10 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
</span>
) : showRuntimeAdvisoryBadge ? (
<span className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<AlertTriangle
className={`size-3.5 shrink-0 ${
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
}`}
/>
<Badge
variant="secondary"
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
runtimeAdvisoryTone === 'error'
? 'bg-red-500/15 text-red-300'
: 'bg-amber-500/15 text-amber-300'
}`}
title={runtimeAdvisoryTitle}
>
{runtimeAdvisoryLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
</TooltipContent>
</Tooltip>
{canRelaunchRuntimeAdvisoryOpenCode ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
{showRuntimeAdvisoryDiagnostics ? (
<MemberLaunchDiagnosticsButton
payload={launchDiagnosticsPayload}
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
/>
) : null}
</span>
) : !activityTask ? (
<Badge
variant="secondary"
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
title={isRemoved ? 'This member has been removed' : activityTitle}
>
{isRemoved ? 'removed' : displayPresenceLabel}
</Badge>
) : null}
{showStartingSkeleton ? (
<div className="shrink-0" aria-hidden="true">
<div
className="skeleton-shimmer h-[18px] w-[62px] rounded-full border"
style={{
backgroundColor: 'var(--skeleton-base-dim)',
borderColor: 'var(--color-border)',
}}
/>
<div
className="skeleton-shimmer mx-1 mt-1 h-[2px] w-10 rounded-full"
style={{ backgroundColor: 'var(--skeleton-base)' }}
/>
</div>
) : (
<div <div
className="shrink-0" data-testid="member-launch-failure-reason"
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined} className="col-span-2 col-start-2 min-w-0 whitespace-pre-wrap break-words text-[10px] font-medium leading-snug text-red-300/90"
title={rawLaunchFailureReason}
> >
<Badge <span>
variant="secondary" {renderLinkifiedText(launchFailureReason, {
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none" linkClassName: 'underline underline-offset-2 hover:text-red-200',
> stopPropagation: true,
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'} getLinkLabel: getLaunchFailureLinkLabel,
</Badge> })}
{totalTasks > 0 && ( </span>
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
{/* NOTE: lead context bar disabled — usage formula is inaccurate */}
</div> </div>
)}
{!isRemoved && (
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
onSendMessage?.();
}}
>
<MessageSquare size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Send message</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
onAssignTask?.();
}}
>
<Plus size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Assign task</TooltipContent>
</Tooltip>
</div>
)}
{canRestoreMember ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={restoringMember ? 'Restoring teammate' : 'Restore teammate'}
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-60"
disabled={restoringMember}
onClick={handleRestoreMember}
>
{restoringMember ? (
<SyncedLoader2 className="size-3.5" />
) : (
<Undo2 className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{restoreMemberError ?? (restoringMember ? 'Restoring teammate...' : 'Restore')}
</TooltipContent>
</Tooltip>
) : null} ) : null}
</div> </div>
</div> </div>
@ -1549,7 +1562,7 @@ export const MemberCard = memo(function MemberCard({
> >
<TooltipTrigger asChild>{cardContent}</TooltipTrigger> <TooltipTrigger asChild>{cardContent}</TooltipTrigger>
<TooltipContent <TooltipContent
side="top" side="left"
align="start" align="start"
sideOffset={8} sideOffset={8}
className="border-blue-400/20 bg-[var(--color-surface)] p-3 shadow-xl shadow-black/30" className="border-blue-400/20 bg-[var(--color-surface)] p-3 shadow-xl shadow-black/30"

View file

@ -2,6 +2,7 @@ import { useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
import { import {
formatMemberLaunchDiagnosticsPayload, formatMemberLaunchDiagnosticsPayload,
type MemberLaunchDiagnosticsPayload, type MemberLaunchDiagnosticsPayload,
@ -13,6 +14,7 @@ interface MemberLaunchDiagnosticsButtonProps {
label?: string; label?: string;
className?: string; className?: string;
size?: 'icon' | 'sm'; size?: 'icon' | 'sm';
attention?: boolean;
} }
export const MemberLaunchDiagnosticsButton = ({ export const MemberLaunchDiagnosticsButton = ({
@ -20,6 +22,7 @@ export const MemberLaunchDiagnosticsButton = ({
label, label,
className, className,
size = label ? 'sm' : 'icon', size = label ? 'sm' : 'icon',
attention = false,
}: MemberLaunchDiagnosticsButtonProps): React.JSX.Element => { }: MemberLaunchDiagnosticsButtonProps): React.JSX.Element => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -45,7 +48,7 @@ export const MemberLaunchDiagnosticsButton = ({
type="button" type="button"
variant="ghost" variant="ghost"
size={size} size={size}
className={className} className={cn(className, attention && !copied && 'member-launch-diagnostics-pulse')}
title={tooltip} title={tooltip}
aria-label={tooltip} aria-label={tooltip}
onClick={copyDiagnostics} onClick={copyDiagnostics}

View file

@ -1056,6 +1056,44 @@ a[href],
} }
} }
@keyframes member-diagnostics-attention-fill {
0%,
100% {
background-color: rgba(239, 68, 68, 0.1);
}
50% {
background-color: rgba(239, 68, 68, 0.22);
}
}
@keyframes member-diagnostics-attention-ring {
0% {
opacity: 0.7;
transform: scale(0.92);
}
70%,
100% {
opacity: 0;
transform: scale(1.38);
}
}
.member-launch-diagnostics-pulse {
position: relative;
overflow: visible;
animation: member-diagnostics-attention-fill 1.7s ease-in-out infinite;
}
.member-launch-diagnostics-pulse::after {
content: '';
position: absolute;
inset: -2px;
border: 1px solid rgba(248, 113, 113, 0.55);
border-radius: inherit;
pointer-events: none;
animation: member-diagnostics-attention-ring 1.7s ease-out infinite;
}
/* Skeleton-style shimmer for waiting members: a translucent light sweep */ /* Skeleton-style shimmer for waiting members: a translucent light sweep */
.member-waiting-shimmer { .member-waiting-shimmer {
position: relative; position: relative;
@ -1653,6 +1691,8 @@ a[href],
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.kanban-comment-badge-pulse, .kanban-comment-badge-pulse,
.member-launch-diagnostics-pulse,
.member-launch-diagnostics-pulse::after,
.message-composer-orbit-path, .message-composer-orbit-path,
.message-composer-orbit-glow { .message-composer-orbit-glow {
animation: none; animation: none;

View file

@ -8,97 +8,6 @@
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="./favicon.png" /> <link rel="icon" type="image/png" href="./favicon.png" />
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/01.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/02.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/03.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/04.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/05.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/06.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/07.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/08.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/09.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/10.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/11.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/12.png"
fetchpriority="high"
/>
<link
rel="preload"
as="image"
type="image/png"
href="./assets/participant-avatars/13.png"
fetchpriority="high"
/>
<title>Agent Teams AI</title> <title>Agent Teams AI</title>
<style> <style>
/* Splash: animated gradient background */ /* Splash: animated gradient background */

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,108 @@
import type {
TeamAgentRuntimeEntry,
TeamAgentRuntimeResourceSample,
TeamAgentRuntimeSnapshot,
} from '@shared/types';
function isTeamAgentRuntimeResourceSampleLike(
value: unknown
): value is TeamAgentRuntimeResourceSample {
return Boolean(value) && typeof value === 'object';
}
export function areTeamAgentRuntimeResourceSamplesEqual(left: unknown, right: unknown): boolean {
if (left === right) return true;
if (!isTeamAgentRuntimeResourceSampleLike(left) || !isTeamAgentRuntimeResourceSampleLike(right)) {
return false;
}
return (
left.timestamp === right.timestamp &&
left.cpuPercent === right.cpuPercent &&
left.rssBytes === right.rssBytes &&
left.primaryCpuPercent === right.primaryCpuPercent &&
left.primaryRssBytes === right.primaryRssBytes &&
left.childCpuPercent === right.childCpuPercent &&
left.childRssBytes === right.childRssBytes &&
left.processCount === right.processCount &&
left.runtimeLoadScope === right.runtimeLoadScope &&
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
left.pidSource === right.pidSource &&
left.pid === right.pid &&
left.runtimePid === right.runtimePid
);
}
export function areTeamAgentRuntimeEntriesEqual(
left: TeamAgentRuntimeEntry | undefined,
right: TeamAgentRuntimeEntry | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
const leftDiagnostics = Array.isArray(left.diagnostics) ? left.diagnostics : [];
const rightDiagnostics = Array.isArray(right.diagnostics) ? right.diagnostics : [];
const leftResourceHistory = Array.isArray(left.resourceHistory) ? left.resourceHistory : [];
const rightResourceHistory = Array.isArray(right.resourceHistory) ? right.resourceHistory : [];
return (
left.memberName === right.memberName &&
left.alive === right.alive &&
left.restartable === right.restartable &&
left.backendType === right.backendType &&
left.providerId === right.providerId &&
left.providerBackendId === right.providerBackendId &&
left.laneId === right.laneId &&
left.laneKind === right.laneKind &&
left.pid === right.pid &&
left.runtimeModel === right.runtimeModel &&
left.rssBytes === right.rssBytes &&
left.cpuPercent === right.cpuPercent &&
left.primaryCpuPercent === right.primaryCpuPercent &&
left.primaryRssBytes === right.primaryRssBytes &&
left.childCpuPercent === right.childCpuPercent &&
left.childRssBytes === right.childRssBytes &&
left.processCount === right.processCount &&
left.runtimeLoadScope === right.runtimeLoadScope &&
left.runtimeLoadTruncated === right.runtimeLoadTruncated &&
left.livenessKind === right.livenessKind &&
left.pidSource === right.pidSource &&
left.processCommand === right.processCommand &&
left.paneId === right.paneId &&
left.panePid === right.panePid &&
left.paneCurrentCommand === right.paneCurrentCommand &&
left.runtimePid === right.runtimePid &&
left.runtimeSessionId === right.runtimeSessionId &&
left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
left.runtimeLastSeenAt === right.runtimeLastSeenAt &&
left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed &&
leftDiagnostics.length === rightDiagnostics.length &&
leftDiagnostics.every((value, index) => value === rightDiagnostics[index]) &&
leftResourceHistory.length === rightResourceHistory.length &&
leftResourceHistory.every((value, index) =>
areTeamAgentRuntimeResourceSamplesEqual(value, rightResourceHistory[index])
)
);
}
export function areTeamAgentRuntimeSnapshotsEqual(
left: TeamAgentRuntimeSnapshot | undefined,
right: TeamAgentRuntimeSnapshot
): boolean {
if (!left) return false;
if (left.teamName !== right.teamName || left.runId !== right.runId) {
return false;
}
const leftKeys = Object.keys(left.members);
const rightKeys = Object.keys(right.members);
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (const key of leftKeys) {
if (!(key in right.members)) {
return false;
}
if (!areTeamAgentRuntimeEntriesEqual(left.members[key], right.members[key])) {
return false;
}
}
return true;
}

View file

@ -0,0 +1,21 @@
const lastResolvedTeamDataRefreshAtByTeam = new Map<string, number>();
export function getLastResolvedTeamDataRefreshAt(teamName: string): number | undefined {
return lastResolvedTeamDataRefreshAtByTeam.get(teamName);
}
export function recordLastResolvedTeamDataRefresh(teamName: string, resolvedAt = Date.now()): void {
lastResolvedTeamDataRefreshAtByTeam.set(teamName, resolvedAt);
}
export function hasLastResolvedTeamDataRefreshAt(teamName: string): boolean {
return lastResolvedTeamDataRefreshAtByTeam.has(teamName);
}
export function clearLastResolvedTeamDataRefreshAt(teamName: string): void {
lastResolvedTeamDataRefreshAtByTeam.delete(teamName);
}
export function clearAllLastResolvedTeamDataRefreshes(): void {
lastResolvedTeamDataRefreshAtByTeam.clear();
}

View file

@ -0,0 +1,39 @@
import type { TeamGetDataOptions } from '@shared/types';
export type TeamDataSnapshotMode = 'full' | 'thin';
export function normalizeTeamGetDataOptions(
options?: TeamGetDataOptions
): TeamGetDataOptions | undefined {
return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined;
}
export function shouldIncludeMemberBranches(options?: TeamGetDataOptions): boolean {
return normalizeTeamGetDataOptions(options)?.includeMemberBranches !== false;
}
export function getTeamDataSnapshotMode(options?: TeamGetDataOptions): TeamDataSnapshotMode {
return shouldIncludeMemberBranches(options) ? 'full' : 'thin';
}
export function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string {
const normalizedOptions = normalizeTeamGetDataOptions(options);
return `${teamName}\u0000mode:${getTeamDataSnapshotMode(normalizedOptions)}`;
}
export function getTeamDataRequestLabel(teamName: string, options?: TeamGetDataOptions): string {
const normalizedOptions = normalizeTeamGetDataOptions(options);
return `team:getData(${teamName},mode=${getTeamDataSnapshotMode(normalizedOptions)})`;
}
export function getFullTeamDataRequestKey(teamName: string): string {
return getTeamDataRequestKey(teamName);
}
export function getThinTeamDataRequestKey(teamName: string): string {
return getTeamDataRequestKey(teamName, { includeMemberBranches: false });
}
export function isTeamDataRequestKeyForTeam(requestKey: string, teamName: string): boolean {
return requestKey.startsWith(`${teamName}\u0000`);
}

View file

@ -0,0 +1,47 @@
import type { TeamViewSnapshot } from '@shared/types';
export interface TeamDataSelectorState {
teamDataCacheByName: Record<string, TeamViewSnapshot>;
selectedTeamName: string | null;
selectedTeamData: TeamViewSnapshot | null;
}
const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamViewSnapshot['members'] = [];
const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = [];
export function selectTeamDataForName(
state: TeamDataSelectorState,
teamName: string | null | undefined
): TeamViewSnapshot | null {
if (!teamName) {
return null;
}
if (state.selectedTeamName === teamName && state.selectedTeamData) {
return state.selectedTeamData;
}
return (
state.teamDataCacheByName[teamName] ??
(state.selectedTeamName === teamName ? state.selectedTeamData : null)
);
}
export function selectTeamMemberSnapshotsForName(
state: TeamDataSelectorState,
teamName: string | null | undefined
): TeamViewSnapshot['members'] {
return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS;
}
export function selectTeamTasksForName(
state: TeamDataSelectorState,
teamName: string | null | undefined
): TeamViewSnapshot['tasks'] {
return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS;
}
export function selectTeamIsAliveForName(
state: TeamDataSelectorState,
teamName: string | null | undefined
): boolean | undefined {
return selectTeamDataForName(state, teamName)?.isAlive;
}

View file

@ -0,0 +1,33 @@
import { IpcError } from '@renderer/utils/unwrapIpc';
function getErrorMessage(error: unknown): string {
return error instanceof IpcError ? error.message : error instanceof Error ? error.message : '';
}
export function mapSendMessageError(error: unknown): string {
const message = getErrorMessage(error);
if (message.includes('Failed to verify inbox write')) {
return 'Message was written but not verified (race). Please try again.';
}
return message || 'Failed to send message';
}
export function mapReviewError(error: unknown): string {
const message = getErrorMessage(error);
if (message.includes('Task status update verification failed')) {
return 'Failed to update task status (possible agent conflict).';
}
return message || 'Failed to perform review action';
}
export function shouldInvalidateCachedTeamDataForError(
teamName: string,
message: string
): boolean {
return (
message === 'TEAM_DRAFT' ||
message.includes('TEAM_DRAFT') ||
message === `Team not found: ${teamName}` ||
message === 'Team config not found'
);
}

View file

@ -0,0 +1,501 @@
import { api } from '@renderer/api';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
getTeamTaskWorkflowColumn,
isTeamTaskFinalForCompletionNotification,
isTeamTaskNeedsFixActionable,
} from '@shared/utils/teamTaskState';
import type { AppConfig } from '@renderer/types/data';
import type { GlobalTask, TaskComment, TeamMessageNotificationData, TeamSummary } from '@shared/types';
const notifiedClarificationTaskKeys = new Set<string>();
const notifiedStatusChangeKeys = new Set<string>();
const notifiedCommentKeys = new Set<string>();
const notifiedCreatedTaskKeys = new Set<string>();
const notifiedAllCompletedTeams = new Set<string>();
const notifiedBlockedTaskKeys = new Set<string>();
let isFirstFetchAllTasks = true;
export interface ProcessGlobalTaskNotificationsParams {
oldTasks: GlobalTask[];
newTasks: GlobalTask[];
appConfig: AppConfig | null;
teamByName: Record<string, TeamSummary>;
isInitialFetch: boolean;
}
export function resetGlobalTaskNotificationTrackerForTests(): void {
notifiedClarificationTaskKeys.clear();
notifiedStatusChangeKeys.clear();
notifiedCommentKeys.clear();
notifiedCreatedTaskKeys.clear();
notifiedAllCompletedTeams.clear();
notifiedBlockedTaskKeys.clear();
isFirstFetchAllTasks = true;
}
export function consumeFirstGlobalTasksFetchFlag(): boolean {
const wasFirst = isFirstFetchAllTasks;
isFirstFetchAllTasks = false;
return wasFirst;
}
export function processGlobalTaskNotifications(
params: ProcessGlobalTaskNotificationsParams
): void {
const { oldTasks, newTasks, appConfig, teamByName, isInitialFetch } = params;
if (isInitialFetch) {
seedGlobalTaskNotificationState(newTasks);
return;
}
const notifyOnClarifications = appConfig?.notifications?.notifyOnClarifications ?? true;
detectClarificationNotifications(oldTasks, newTasks, notifyOnClarifications);
detectBlockedTaskNotifications(oldTasks, newTasks, notifyOnClarifications);
detectStatusChangeNotifications(oldTasks, newTasks, appConfig, teamByName);
const notifyOnTaskComments = appConfig?.notifications?.notifyOnTaskComments ?? true;
detectTaskCommentNotifications(oldTasks, newTasks, notifyOnTaskComments);
const notifyOnTaskCreated = appConfig?.notifications?.notifyOnTaskCreated ?? true;
detectTaskCreatedNotifications(oldTasks, newTasks, notifyOnTaskCreated);
const notifyOnAllCompleted = appConfig?.notifications?.notifyOnAllTasksCompleted ?? true;
detectAllTasksCompletedNotification(oldTasks, newTasks, notifyOnAllCompleted);
}
function seedGlobalTaskNotificationState(tasks: readonly GlobalTask[]): void {
for (const task of tasks) {
if (task.needsClarification === 'user') {
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
}
if ((task.blockedBy?.length ?? 0) > 0) {
notifiedBlockedTaskKeys.add(`${task.teamName}:${task.id}:${(task.blockedBy ?? []).join(',')}`);
}
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
if (isTeamTaskNeedsFixActionable(task)) {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
}
if (getTeamTaskWorkflowColumn(task) === 'approved') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
}
if (getTeamTaskWorkflowColumn(task) === 'review') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
}
for (const comment of task.comments ?? []) {
notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`);
}
notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`);
}
const teamTasksMap = new Map<string, GlobalTask[]>();
for (const task of tasks) {
const list = teamTasksMap.get(task.teamName) ?? [];
list.push(task);
teamTasksMap.set(task.teamName, list);
}
for (const [teamName, teamTasks] of teamTasksMap) {
if (teamTasks.every(isTeamTaskFinalForCompletionNotification)) {
notifiedAllCompletedTeams.add(teamName);
}
}
}
function showTeamNotification(data: TeamMessageNotificationData): void {
void api.teams?.showMessageNotification(data).catch(() => undefined);
}
function detectClarificationNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
for (const task of newTasks) {
const key = `${task.teamName}:${task.id}`;
if (task.needsClarification === 'user') {
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
if (oldTask?.needsClarification !== 'user' && !notifiedClarificationTaskKeys.has(key)) {
notifiedClarificationTaskKeys.add(key);
showClarificationNotification(task, !notifyEnabled);
}
} else {
notifiedClarificationTaskKeys.delete(key);
}
}
}
function showClarificationNotification(task: GlobalTask, suppressToast: boolean): void {
const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined;
const rawBody =
latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`;
const body = stripAgentBlocks(rawBody).trim();
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: latestComment?.author || 'team-lead',
to: 'user',
summary: `Clarification needed — Task ${formatTaskDisplayLabel(task)}`,
body,
teamEventType: 'task_clarification',
dedupeKey: `clarification:${task.teamName}:${task.id}:${task.updatedAt ?? Date.now()}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
commentId: latestComment?.id,
focus: 'comments',
},
suppressToast,
});
}
function detectStatusChangeNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
config: AppConfig | null,
teamByName: Record<string, TeamSummary>
): void {
const statusChangeEnabled =
!!config?.notifications?.notifyOnStatusChange && !!config.notifications.enabled;
const statuses = config?.notifications?.statusChangeStatuses ?? ['in_progress', 'completed'];
if (statuses.length === 0) return;
const onlySolo = config?.notifications?.statusChangeOnlySolo ?? true;
for (const task of newTasks) {
const oldTask = oldTasks.find((t) => t.teamName === task.teamName && t.id === task.id);
if (!oldTask) continue;
const taskKanbanColumn = getTeamTaskWorkflowColumn(task);
const oldTaskKanbanColumn = getTeamTaskWorkflowColumn(oldTask);
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review';
const becameNeedsFix =
isTeamTaskNeedsFixActionable(task) && !isTeamTaskNeedsFixActionable(oldTask);
const statusChanged = oldTask.status !== task.status;
if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
if (onlySolo) {
const team = teamByName[task.teamName];
if (team && team.memberCount > 0) continue;
}
const effectiveStatus = becameApproved
? 'approved'
: becameReview
? 'review'
: becameNeedsFix
? 'needsFix'
: task.status;
if (!statuses.includes(effectiveStatus)) continue;
const key = `${task.teamName}:${task.id}:${effectiveStatus}`;
if (notifiedStatusChangeKeys.has(key)) continue;
notifiedStatusChangeKeys.add(key);
const fromLabel = becameApproved ? 'Completed' : becameReview ? 'Completed' : oldTask.status;
showStatusChangeNotification(
task,
fromLabel,
becameApproved
? 'approved'
: becameReview
? 'review'
: becameNeedsFix
? 'needsFix'
: undefined,
!statusChangeEnabled
);
}
}
function showStatusChangeNotification(
task: GlobalTask,
fromStatus: string,
overrideToStatus?: string,
suppressToast?: boolean
): void {
const statusLabels: Record<string, string> = {
pending: 'Pending',
in_progress: 'In Progress',
completed: 'Completed',
deleted: 'Deleted',
review: 'Review',
needsFix: 'Needs Fixes',
approved: 'Approved',
};
const from = statusLabels[fromStatus] ?? fromStatus;
const toStatus = overrideToStatus ?? task.status;
const to = statusLabels[toStatus] ?? toStatus;
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: task.owner ?? 'system',
to: 'user',
summary: `Task ${formatTaskDisplayLabel(task)}: ${from}${to}`,
body: task.subject,
teamEventType: 'task_status_change',
dedupeKey: `status:${task.teamName}:${task.id}:${fromStatus}:${toStatus}:${task.updatedAt ?? Date.now()}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
focus: 'status',
},
suppressToast,
});
}
function detectTaskCommentNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
const oldTaskMap = new Map(oldTasks.map((t) => [`${t.teamName}:${t.id}`, t]));
for (const task of newTasks) {
const mapKey = `${task.teamName}:${task.id}`;
const oldTask = oldTaskMap.get(mapKey);
const oldCommentCount = oldTask?.comments?.length ?? 0;
const newCommentCount = task.comments?.length ?? 0;
if (newCommentCount <= oldCommentCount) continue;
const newComments = (task.comments ?? []).slice(oldCommentCount);
for (const comment of newComments) {
if (comment.author === 'user') continue;
const key = `${task.teamName}:${task.id}:${comment.id}`;
if (notifiedCommentKeys.has(key)) continue;
notifiedCommentKeys.add(key);
if (comment.type === 'review_request') {
showTaskReviewRequestedNotification(task, comment, !notifyEnabled);
continue;
}
if (comment.type === 'review_approved') continue;
showTaskCommentNotification(task, comment, !notifyEnabled);
}
}
}
function showTaskCommentNotification(
task: GlobalTask,
comment: Pick<TaskComment, 'author' | 'text' | 'id'>,
suppressToast: boolean
): void {
if (comment.author === 'user') return;
const stripped = stripAgentBlocks(comment.text).trim();
const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped;
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: comment.author,
to: 'user',
summary: `Comment on ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: preview,
teamEventType: 'task_comment',
dedupeKey: `comment:${task.teamName}:${task.id}:${comment.id}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
commentId: comment.id,
focus: 'comments',
},
suppressToast,
});
}
function showTaskReviewRequestedNotification(
task: GlobalTask,
comment: Pick<TaskComment, 'author' | 'text' | 'id'>,
suppressToast: boolean
): void {
const stripped = stripAgentBlocks(comment.text).trim();
const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped;
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: comment.author,
to: 'user',
summary: `Review requested ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: preview || task.subject,
teamEventType: 'task_review_requested',
dedupeKey: `review-request:${task.teamName}:${task.id}:${comment.id}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
commentId: comment.id,
focus: 'review',
},
suppressToast,
});
}
function detectBlockedTaskNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
const oldTaskMap = new Map(oldTasks.map((task) => [`${task.teamName}:${task.id}`, task]));
for (const task of newTasks) {
const oldTask = oldTaskMap.get(`${task.teamName}:${task.id}`);
const oldBlockedBy = new Set(oldTask?.blockedBy?.filter(Boolean) ?? []);
const newBlockedBy = Array.from(new Set(task.blockedBy?.filter(Boolean) ?? []));
const taskKeyPrefix = `${task.teamName}:${task.id}:`;
const key = `${taskKeyPrefix}${[...newBlockedBy].sort().join(',')}`;
const addedBlockedBy = newBlockedBy.filter((id) => !oldBlockedBy.has(id));
for (const existingKey of Array.from(notifiedBlockedTaskKeys)) {
if (existingKey.startsWith(taskKeyPrefix) && existingKey !== key) {
notifiedBlockedTaskKeys.delete(existingKey);
}
}
if (newBlockedBy.length > 0 && addedBlockedBy.length > 0) {
if (notifiedBlockedTaskKeys.has(key)) continue;
notifiedBlockedTaskKeys.add(key);
showTaskBlockedNotification(task, newBlockedBy, !notifyEnabled);
} else if (newBlockedBy.length === 0) {
for (const existingKey of Array.from(notifiedBlockedTaskKeys)) {
if (existingKey.startsWith(taskKeyPrefix)) {
notifiedBlockedTaskKeys.delete(existingKey);
}
}
}
}
}
function showTaskBlockedNotification(
task: GlobalTask,
blockedBy: readonly string[],
suppressToast: boolean
): void {
const blockerRefs = blockedBy.map((id) => formatTaskDisplayLabel({ id })).join(', ');
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: task.owner ?? 'system',
to: 'user',
summary: `Blocked ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: blockerRefs ? `Blocked by ${blockerRefs}` : task.subject,
teamEventType: 'task_blocked',
dedupeKey: `blocked:${task.teamName}:${task.id}:${blockedBy.join(',')}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
focus: 'detail',
},
suppressToast,
});
}
function detectTaskCreatedNotifications(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
const oldTaskKeys = new Set(oldTasks.map((t) => `${t.teamName}:${t.id}`));
for (const task of newTasks) {
const key = `${task.teamName}:${task.id}`;
if (oldTaskKeys.has(key)) continue;
if (notifiedCreatedTaskKeys.has(key)) continue;
notifiedCreatedTaskKeys.add(key);
showTaskCreatedNotification(task, !notifyEnabled);
}
}
function showTaskCreatedNotification(task: GlobalTask, suppressToast: boolean): void {
showTeamNotification({
teamName: task.teamName,
teamDisplayName: task.teamDisplayName,
from: task.owner ?? 'system',
to: 'user',
summary: `New task ${formatTaskDisplayLabel(task)}: ${task.subject}`,
body: stripAgentBlocks(task.description || task.subject).trim(),
teamEventType: 'task_created',
dedupeKey: `created:${task.teamName}:${task.id}`,
target: {
kind: 'task',
teamName: task.teamName,
taskId: task.id,
focus: 'detail',
},
suppressToast,
});
}
function detectAllTasksCompletedNotification(
oldTasks: GlobalTask[],
newTasks: GlobalTask[],
notifyEnabled: boolean
): void {
const teamTasks = new Map<string, GlobalTask[]>();
for (const task of newTasks) {
const list = teamTasks.get(task.teamName) ?? [];
list.push(task);
teamTasks.set(task.teamName, list);
}
for (const [teamName, tasks] of teamTasks) {
if (tasks.length === 0) continue;
const allCompleted = tasks.every(isTeamTaskFinalForCompletionNotification);
if (!allCompleted) {
notifiedAllCompletedTeams.delete(teamName);
continue;
}
if (notifiedAllCompletedTeams.has(teamName)) continue;
const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName);
const wasAlreadyAllCompleted =
oldTeamTasks.length > 0 && oldTeamTasks.every(isTeamTaskFinalForCompletionNotification);
if (wasAlreadyAllCompleted) {
notifiedAllCompletedTeams.add(teamName);
continue;
}
notifiedAllCompletedTeams.add(teamName);
showAllTasksCompletedNotification(tasks[0], tasks.length, !notifyEnabled);
}
}
function showAllTasksCompletedNotification(
sampleTask: GlobalTask,
taskCount: number,
suppressToast: boolean
): void {
showTeamNotification({
teamName: sampleTask.teamName,
teamDisplayName: sampleTask.teamDisplayName,
from: 'system',
to: 'user',
summary: `All ${taskCount} tasks completed`,
body: `All tasks in team "${sampleTask.teamDisplayName}" are done`,
teamEventType: 'all_tasks_completed',
dedupeKey: `all-done:${sampleTask.teamName}:${Date.now()}`,
target: {
kind: 'team',
teamName: sampleTask.teamName,
section: 'tasks',
},
suppressToast,
});
}

View file

@ -0,0 +1,219 @@
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
import type { TeamMemberSnapshot, TeamViewSnapshot } from '@shared/types';
export const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const;
export const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true;
export type TeamGraphSlotAssignments = Record<string, GraphOwnerSlotAssignment>;
export type TeamGraphMemberSeedInput = Pick<TeamMemberSnapshot, 'name' | 'agentId' | 'removedAt'>;
export type TeamGraphConfigMemberSeedInput = Pick<
NonNullable<TeamViewSnapshot['config']['members']>[number],
'name' | 'agentId' | 'removedAt'
>;
export interface TeamGraphLayoutSessionState {
mode: 'default' | 'manual';
signature: string | null;
}
export function migrateStableSlotAssignmentsForMembers(
assignments: TeamGraphSlotAssignments | undefined,
members: readonly TeamGraphMemberSeedInput[]
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) };
let changed = false;
for (const member of members) {
const fallbackKey = member.name.trim();
const stableOwnerId = getStableTeamOwnerId(member);
const fallbackAssignment = nextAssignments[fallbackKey];
const stableAssignment = nextAssignments[stableOwnerId];
if (stableOwnerId !== fallbackKey && fallbackAssignment && !stableAssignment) {
nextAssignments[stableOwnerId] = fallbackAssignment;
delete nextAssignments[fallbackKey];
changed = true;
continue;
}
if (stableOwnerId !== fallbackKey && fallbackAssignment && stableAssignment) {
delete nextAssignments[fallbackKey];
changed = true;
}
}
return { assignments: nextAssignments, changed };
}
export function seedStableSlotAssignmentsForMembers(
assignments: TeamGraphSlotAssignments,
members: readonly TeamGraphMemberSeedInput[],
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
): { assignments: TeamGraphSlotAssignments; changed: boolean } {
const defaultSeed = buildTeamGraphDefaultLayoutSeed(members, configMembers);
if (
defaultSeed.orderedVisibleOwnerIds.length === 0 ||
Object.keys(defaultSeed.assignments).length === 0
) {
return { assignments, changed: false };
}
const visibleStableOwnerIds = defaultSeed.orderedVisibleOwnerIds;
const hasAnyVisibleAssignments = visibleStableOwnerIds.some(
(stableOwnerId) => assignments[stableOwnerId] != null
);
if (hasAnyVisibleAssignments) {
return { assignments, changed: false };
}
const nextAssignments: TeamGraphSlotAssignments = { ...assignments };
visibleStableOwnerIds.forEach((stableOwnerId) => {
nextAssignments[stableOwnerId] = defaultSeed.assignments[stableOwnerId]!;
});
return { assignments: nextAssignments, changed: true };
}
export function areTeamGraphSlotAssignmentsEqual(
left: TeamGraphSlotAssignments | undefined,
right: TeamGraphSlotAssignments | undefined
): boolean {
const leftEntries = Object.entries(left ?? {});
const rightEntries = Object.entries(right ?? {});
if (leftEntries.length !== rightEntries.length) {
return false;
}
for (const [stableOwnerId, leftAssignment] of leftEntries) {
const rightAssignment = right?.[stableOwnerId];
if (
rightAssignment?.ringIndex !== leftAssignment.ringIndex ||
rightAssignment.sectorIndex !== leftAssignment.sectorIndex
) {
return false;
}
}
return true;
}
export function normalizeTeamGraphSlotAssignmentsForVisibleOwners(
assignments: TeamGraphSlotAssignments | undefined,
visibleOwnerIds: readonly string[]
): TeamGraphSlotAssignments {
if (visibleOwnerIds.length === 0 || !assignments) {
return {};
}
const normalizedAssignments: TeamGraphSlotAssignments = {};
for (const stableOwnerId of visibleOwnerIds) {
const assignment = assignments[stableOwnerId];
if (!assignment) {
continue;
}
normalizedAssignments[stableOwnerId] = assignment;
}
return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds);
}
export function normalizeLegacySixRowOrbitAssignments(
assignments: TeamGraphSlotAssignments,
visibleOwnerIds: readonly string[]
): TeamGraphSlotAssignments {
if (visibleOwnerIds.length !== 6) {
return assignments;
}
const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => {
const assignment = assignments[stableOwnerId];
return assignment ? [assignment] : [];
});
const hasLegacyTwoRowBottomMarker = visibleAssignments.some(
(assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
);
let changed = false;
const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments };
for (const stableOwnerId of visibleOwnerIds) {
const assignment = normalizedAssignments[stableOwnerId];
if (!assignment) {
continue;
}
if (
hasLegacyTwoRowBottomMarker &&
assignment.ringIndex === 1 &&
assignment.sectorIndex >= 0 &&
assignment.sectorIndex < 3
) {
normalizedAssignments[stableOwnerId] = {
ringIndex: 2,
sectorIndex: assignment.sectorIndex,
};
changed = true;
continue;
}
if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) {
normalizedAssignments[stableOwnerId] = {
ringIndex: 2,
sectorIndex: assignment.sectorIndex - 3,
};
changed = true;
}
}
return changed ? normalizedAssignments : assignments;
}
export function pruneTeamGraphSlotAssignmentsForVisibleOwners(
assignments: TeamGraphSlotAssignments | undefined,
visibleOwnerIds: readonly string[]
): TeamGraphSlotAssignments | undefined {
const normalizedAssignments = normalizeTeamGraphSlotAssignmentsForVisibleOwners(
assignments,
visibleOwnerIds
);
return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined;
}
export function normalizeTeamGraphGridOwnerOrder(
order: readonly string[] | undefined,
visibleOwnerIds: readonly string[]
): string[] {
const visibleOwnerIdSet = new Set(visibleOwnerIds);
const normalizedOrder: string[] = [];
const seenOwnerIds = new Set<string>();
for (const stableOwnerId of order ?? []) {
if (!visibleOwnerIdSet.has(stableOwnerId) || seenOwnerIds.has(stableOwnerId)) {
continue;
}
normalizedOrder.push(stableOwnerId);
seenOwnerIds.add(stableOwnerId);
}
for (const stableOwnerId of visibleOwnerIds) {
if (seenOwnerIds.has(stableOwnerId)) {
continue;
}
normalizedOrder.push(stableOwnerId);
seenOwnerIds.add(stableOwnerId);
}
return normalizedOrder;
}
export function getDefaultTeamGraphSlotAssignmentsForMembers(
members: readonly TeamGraphMemberSeedInput[],
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
): TeamGraphSlotAssignments {
return buildTeamGraphDefaultLayoutSeed(members, configMembers).assignments;
}
export function isTeamGraphSlotPersistenceDisabled(): boolean {
return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS;
}

View file

@ -0,0 +1,89 @@
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import type {
EffortLevel,
TeamCreateRequest,
TeamFastMode,
TeamProviderId,
} from '@shared/types';
/** Per-team launch parameters shown in the header badge. */
export interface TeamLaunchParams {
providerId?: TeamProviderId;
providerBackendId?: string;
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
limitContext?: boolean;
}
export function extractBaseModel(
raw?: string,
providerId?: TeamProviderId
): string | undefined {
return extractProviderScopedBaseModel(raw, providerId);
}
export function buildLaunchParamsFromRuntimeRequest(
request: Pick<
TeamCreateRequest,
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext'
>,
fallback?: TeamLaunchParams
): TeamLaunchParams {
const providerId = request.providerId ?? fallback?.providerId ?? 'anthropic';
const providerChanged =
request.providerId != null &&
fallback?.providerId != null &&
request.providerId !== fallback.providerId;
const hasModel = Object.hasOwn(request, 'model');
const baseModel =
hasModel && typeof request.model === 'string'
? extractBaseModel(request.model, providerId)
: undefined;
const rawProviderBackendId = Object.hasOwn(request, 'providerBackendId')
? request.providerBackendId
: providerChanged
? undefined
: fallback?.providerBackendId;
return {
providerId,
providerBackendId: migrateProviderBackendId(providerId, rawProviderBackendId),
model: hasModel
? baseModel || 'default'
: (providerChanged ? undefined : fallback?.model) || 'default',
effort: Object.hasOwn(request, 'effort')
? request.effort
: providerChanged
? undefined
: fallback?.effort,
fastMode: Object.hasOwn(request, 'fastMode')
? request.fastMode
: providerChanged
? undefined
: fallback?.fastMode,
limitContext:
typeof request.limitContext === 'boolean'
? request.limitContext
: providerChanged
? false
: (fallback?.limitContext ?? false),
};
}
export function areTeamLaunchParamsEqual(
left: TeamLaunchParams | undefined,
right: TeamLaunchParams | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return false;
return (
left.providerId === right.providerId &&
left.providerBackendId === right.providerBackendId &&
left.model === right.model &&
left.effort === right.effort &&
left.fastMode === right.fastMode &&
left.limitContext === right.limitContext
);
}

View file

@ -0,0 +1,25 @@
const teamLocalStateEpochByTeam = new Map<string, number>();
export function captureTeamLocalStateEpoch(teamName: string): number {
return teamLocalStateEpochByTeam.get(teamName) ?? 0;
}
export function isTeamLocalStateEpochCurrent(teamName: string, epoch: number): boolean {
return captureTeamLocalStateEpoch(teamName) === epoch;
}
export function invalidateTeamLocalStateEpoch(teamName: string): void {
teamLocalStateEpochByTeam.set(teamName, captureTeamLocalStateEpoch(teamName) + 1);
}
export function hasTeamLocalStateEpoch(teamName: string): boolean {
return teamLocalStateEpochByTeam.has(teamName);
}
export function clearTeamLocalStateEpoch(teamName: string): void {
teamLocalStateEpochByTeam.delete(teamName);
}
export function clearAllTeamLocalStateEpochs(): void {
teamLocalStateEpochByTeam.clear();
}

View file

@ -0,0 +1,64 @@
import { getTeamMessagesCacheEntry, type TeamMessagesCacheState } from './teamMessagesCache';
import type { MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types';
export interface TeamMemberActivityMetaState extends TeamMessagesCacheState {
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
}
export function areMemberActivityMetaEntriesEqual(
left: MemberActivityMetaEntry | undefined,
right: MemberActivityMetaEntry
): boolean {
if (!left) {
return false;
}
return (
left.memberName === right.memberName &&
left.lastAuthoredMessageAt === right.lastAuthoredMessageAt &&
left.messageCountExact === right.messageCountExact &&
left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination
);
}
export function structurallyShareMemberActivityFacts(
previous: Record<string, MemberActivityMetaEntry> | undefined,
next: Record<string, MemberActivityMetaEntry>
): Record<string, MemberActivityMetaEntry> {
if (!previous) {
return next;
}
const nextKeys = Object.keys(next);
const previousKeys = Object.keys(previous);
let changed = nextKeys.length !== previousKeys.length;
const shared: Record<string, MemberActivityMetaEntry> = {};
for (const key of nextKeys) {
const nextEntry = next[key];
const previousEntry = previous[key];
if (!areMemberActivityMetaEntriesEqual(previousEntry, nextEntry)) {
changed = true;
shared[key] = nextEntry;
continue;
}
shared[key] = previousEntry;
}
return changed ? shared : previous;
}
export function isMemberActivityMetaStale(
state: TeamMemberActivityMetaState,
teamName: string
): boolean {
const meta = state.memberActivityMetaByTeam[teamName];
const feedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision;
if (!meta) {
return true;
}
if (!feedRevision) {
return false;
}
return meta.feedRevision !== feedRevision;
}

View file

@ -0,0 +1,106 @@
import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
PersistedTeamLaunchSummary,
} from '@shared/types';
export function areLaunchSummaryCountsEqual(
left: PersistedTeamLaunchSummary | undefined,
right: PersistedTeamLaunchSummary | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
return (
left.confirmedCount === right.confirmedCount &&
left.pendingCount === right.pendingCount &&
left.failedCount === right.failedCount &&
left.skippedCount === right.skippedCount &&
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount &&
left.noRuntimePendingCount === right.noRuntimePendingCount &&
left.permissionPendingCount === right.permissionPendingCount
);
}
export function areExpectedMembersEqual(
left: readonly string[] | undefined,
right: readonly string[] | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
export function areMemberSpawnStatusEntriesEqual(
left: MemberSpawnStatusEntry | undefined,
right: MemberSpawnStatusEntry | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
const leftPendingPermissionIds = [...(left.pendingPermissionRequestIds ?? [])].sort();
const rightPendingPermissionIds = [...(right.pendingPermissionRequestIds ?? [])].sort();
// Renderer equality intentionally ignores raw timing fields that do not change
// visible member status. This suppresses heartbeat-only churn in TeamDetailView.
return (
left.status === right.status &&
left.launchState === right.launchState &&
left.error === right.error &&
left.hardFailureReason === right.hardFailureReason &&
left.skippedForLaunch === right.skippedForLaunch &&
left.skipReason === right.skipReason &&
left.skippedAt === right.skippedAt &&
left.livenessSource === right.livenessSource &&
left.runtimeAlive === right.runtimeAlive &&
left.runtimeModel === right.runtimeModel &&
left.livenessKind === right.livenessKind &&
left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
left.bootstrapConfirmed === right.bootstrapConfirmed &&
left.hardFailure === right.hardFailure &&
leftPendingPermissionIds.length === rightPendingPermissionIds.length &&
leftPendingPermissionIds.every((value, index) => value === rightPendingPermissionIds[index])
);
}
export function areMemberSpawnStatusesEqual(
left: Record<string, MemberSpawnStatusEntry>,
right: Record<string, MemberSpawnStatusEntry>
): boolean {
if (left === right) return true;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (!(key in right)) {
return false;
}
if (!areMemberSpawnStatusEntriesEqual(left[key], right[key])) {
return false;
}
}
return true;
}
export function areMemberSpawnSnapshotsSemanticallyEqual(
left: MemberSpawnStatusesSnapshot | undefined,
right: MemberSpawnStatusesSnapshot
): boolean {
if (!left) return false;
return (
left.runId === right.runId &&
left.teamLaunchState === right.teamLaunchState &&
left.launchPhase === right.launchPhase &&
left.source === right.source &&
areExpectedMembersEqual(left.expectedMembers, right.expectedMembers) &&
areLaunchSummaryCountsEqual(left.summary, right.summary) &&
areMemberSpawnStatusesEqual(left.statuses, right.statuses)
);
}

View file

@ -0,0 +1,39 @@
const memberSpawnStatusesIpcBackoffUntilByTeam = new Map<string, number>();
export function getMemberSpawnStatusesIpcBackoffUntil(teamName: string): number {
return memberSpawnStatusesIpcBackoffUntilByTeam.get(teamName) ?? 0;
}
export function hasMemberSpawnStatusesIpcBackoff(teamName: string): boolean {
return memberSpawnStatusesIpcBackoffUntilByTeam.has(teamName);
}
export function isMemberSpawnStatusesIpcBackoffActive(
teamName: string,
now = Date.now()
): boolean {
return getMemberSpawnStatusesIpcBackoffUntil(teamName) > now;
}
export function recordMemberSpawnStatusesIpcBackoffUntil(
teamName: string,
backoffUntil: number
): void {
memberSpawnStatusesIpcBackoffUntilByTeam.set(teamName, backoffUntil);
}
export function recordMemberSpawnStatusesIpcRetryBackoff(
teamName: string,
retryBackoffMs: number,
now = Date.now()
): void {
recordMemberSpawnStatusesIpcBackoffUntil(teamName, now + retryBackoffMs);
}
export function clearMemberSpawnStatusesIpcBackoff(teamName: string): void {
memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName);
}
export function clearAllMemberSpawnStatusesIpcBackoffs(): void {
memberSpawnStatusesIpcBackoffUntilByTeam.clear();
}

View file

@ -0,0 +1,30 @@
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
export function getMemberSpawnUiEqualLastWarnAt(teamName: string): number | undefined {
return memberSpawnUiEqualLastWarnAtByTeam.get(teamName);
}
export function hasMemberSpawnUiEqualLastWarn(teamName: string): boolean {
return memberSpawnUiEqualLastWarnAtByTeam.has(teamName);
}
export function shouldLogMemberSpawnUiEqualSuppressed(
teamName: string,
throttleMs: number,
now = Date.now()
): boolean {
const lastWarnAt = memberSpawnUiEqualLastWarnAtByTeam.get(teamName) ?? 0;
if (now - lastWarnAt < throttleMs) {
return false;
}
memberSpawnUiEqualLastWarnAtByTeam.set(teamName, now);
return true;
}
export function clearMemberSpawnUiEqualLastWarn(teamName: string): void {
memberSpawnUiEqualLastWarnAtByTeam.delete(teamName);
}
export function clearAllMemberSpawnUiEqualLastWarns(): void {
memberSpawnUiEqualLastWarnAtByTeam.clear();
}

View file

@ -0,0 +1,291 @@
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import type { InboxMessage } from '@shared/types';
export interface TeamMessagesCacheEntry {
canonicalMessages: InboxMessage[];
optimisticMessages: InboxMessage[];
feedRevision: string | null;
nextCursor: string | null;
hasMore: boolean;
lastFetchedAt: number | null;
loadingHead: boolean;
loadingOlder: boolean;
headHydrated: boolean;
}
export interface RefreshTeamMessagesHeadResult {
feedChanged: boolean;
headChanged: boolean;
feedRevision: string | null;
}
export interface TeamMessagesCacheState {
teamMessagesByName: Record<string, TeamMessagesCacheEntry>;
}
export interface TeamMessageSelectorCacheSnapshot {
hasMergedMessagesSelector: boolean;
memberMessagesSelectorCount: number;
}
export const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: null,
nextCursor: null,
hasMore: false,
lastFetchedAt: null,
loadingHead: false,
loadingOlder: false,
headHydrated: false,
};
const mergedMessagesSelectorCache = new Map<
string,
{
canonicalRef: readonly InboxMessage[];
optimisticRef: readonly InboxMessage[];
result: InboxMessage[];
}
>();
const memberMessagesSelectorCache = new Map<
string,
{
messagesRef: readonly InboxMessage[];
result: InboxMessage[];
}
>();
export function clearTeamMessageSelectorCaches(): void {
mergedMessagesSelectorCache.clear();
memberMessagesSelectorCache.clear();
}
export function clearTeamMessageSelectorCachesForTeam(teamName: string): void {
mergedMessagesSelectorCache.delete(teamName);
const teamScopedPrefix = `${teamName}:`;
for (const key of memberMessagesSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
memberMessagesSelectorCache.delete(key);
}
}
}
export function getTeamMessageSelectorCacheSnapshotForTeam(
teamName: string
): TeamMessageSelectorCacheSnapshot {
const teamScopedPrefix = `${teamName}:`;
let memberMessagesSelectorCount = 0;
for (const key of memberMessagesSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
memberMessagesSelectorCount += 1;
}
}
return {
hasMergedMessagesSelector: mergedMessagesSelectorCache.has(teamName),
memberMessagesSelectorCount,
};
}
export function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number {
const aTime = Date.parse(a.timestamp);
const bTime = Date.parse(b.timestamp);
const aValid = Number.isFinite(aTime);
const bValid = Number.isFinite(bTime);
if (aValid && bValid && aTime !== bTime) {
return aTime - bTime;
}
if (aValid !== bValid) {
return aValid ? -1 : 1;
}
const aId = typeof a.messageId === 'string' ? a.messageId : '';
const bId = typeof b.messageId === 'string' ? b.messageId : '';
return aId.localeCompare(bId);
}
export function getTeamMessagesCacheEntry(
state: TeamMessagesCacheState,
teamName: string
): TeamMessagesCacheEntry {
return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY;
}
export function upsertOptimisticTeamMessage(
entry: TeamMessagesCacheEntry,
message: InboxMessage
): TeamMessagesCacheEntry {
const nextOptimistic = [...entry.optimisticMessages];
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (messageId.length > 0) {
const existingIndex = nextOptimistic.findIndex(
(candidate) =>
typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId
);
if (existingIndex >= 0) {
nextOptimistic[existingIndex] = {
...nextOptimistic[existingIndex],
...message,
};
} else {
nextOptimistic.push(message);
}
} else {
nextOptimistic.push(message);
}
nextOptimistic.sort(compareInboxMessagesByTimestamp);
return {
...entry,
optimisticMessages: nextOptimistic,
};
}
export function areInboxMessageArraysEquivalent(
left: readonly InboxMessage[],
right: readonly InboxMessage[]
): boolean {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const leftItem = left[index];
const rightItem = right[index];
if (
leftItem.messageId !== rightItem.messageId ||
leftItem.timestamp !== rightItem.timestamp ||
leftItem.from !== rightItem.from ||
leftItem.to !== rightItem.to ||
leftItem.text !== rightItem.text ||
leftItem.summary !== rightItem.summary ||
leftItem.read !== rightItem.read ||
leftItem.actionMode !== rightItem.actionMode ||
leftItem.commentId !== rightItem.commentId ||
leftItem.relayOfMessageId !== rightItem.relayOfMessageId ||
leftItem.source !== rightItem.source ||
leftItem.leadSessionId !== rightItem.leadSessionId ||
leftItem.messageKind !== rightItem.messageKind ||
JSON.stringify(leftItem.taskRefs ?? null) !== JSON.stringify(rightItem.taskRefs ?? null)
) {
return false;
}
}
return true;
}
export function pruneOptimisticMessages(
optimistic: readonly InboxMessage[],
canonical: readonly InboxMessage[]
): InboxMessage[] {
if (optimistic.length === 0) {
return [];
}
const canonicalIds = new Set(
canonical
.map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : ''))
.filter((messageId) => messageId.length > 0)
);
return optimistic.filter((message) => {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
return !messageId || !canonicalIds.has(messageId);
});
}
export function getCanonicalHeadSlice(
canonicalMessages: readonly InboxMessage[],
headLength: number
): readonly InboxMessage[] {
if (headLength <= 0) {
return [];
}
return canonicalMessages.slice(0, headLength);
}
export function extractRetainedCanonicalOlderTail(
canonicalMessages: readonly InboxMessage[],
freshHeadMessages: readonly InboxMessage[]
): InboxMessage[] | null {
if (canonicalMessages.length === 0) {
return [];
}
if (freshHeadMessages.length === 0) {
return null;
}
const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message)));
let hasMessagesOutsideFreshHead = false;
for (const message of canonicalMessages) {
if (!freshHeadKeys.has(toMessageKey(message))) {
hasMessagesOutsideFreshHead = true;
break;
}
}
if (!hasMessagesOutsideFreshHead) {
return [];
}
const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]);
const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey);
if (anchorIndex < 0) {
return null;
}
return canonicalMessages
.slice(anchorIndex + 1)
.filter((message) => !freshHeadKeys.has(toMessageKey(message)));
}
export function selectTeamMessages(
state: TeamMessagesCacheState,
teamName: string | null | undefined
): InboxMessage[] {
if (!teamName) {
return [];
}
const entry = getTeamMessagesCacheEntry(state, teamName);
const cached = mergedMessagesSelectorCache.get(teamName);
if (
cached?.canonicalRef === entry.canonicalMessages &&
cached.optimisticRef === entry.optimisticMessages
) {
return cached.result;
}
const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages);
mergedMessagesSelectorCache.set(teamName, {
canonicalRef: entry.canonicalMessages,
optimisticRef: entry.optimisticMessages,
result,
});
return result;
}
export function selectMemberMessagesForTeamMember(
state: TeamMessagesCacheState,
teamName: string | null | undefined,
memberName: string | null | undefined
): InboxMessage[] {
if (!teamName || !memberName) {
return [];
}
const messages = selectTeamMessages(state, teamName);
const cacheKey = `${teamName}:${memberName}`;
const cached = memberMessagesSelectorCache.get(cacheKey);
if (cached?.messagesRef === messages) {
return cached.result;
}
const result = messages.filter(
(message) => message.from === memberName || message.to === memberName
);
memberMessagesSelectorCache.set(cacheKey, {
messagesRef: messages,
result,
});
return result;
}

View file

@ -0,0 +1,29 @@
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
const MESSAGES_PANEL_MODE_STORAGE_KEY = 'team:messagesPanelMode';
const DEFAULT_MESSAGES_PANEL_MODE: TeamMessagesPanelMode = 'sidebar';
const VALID_MESSAGES_PANEL_MODES: ReadonlySet<TeamMessagesPanelMode> = new Set([
'sidebar',
'inline',
'bottom-sheet',
'floating-composer',
]);
export function loadPersistedMessagesPanelMode(): TeamMessagesPanelMode {
try {
const persisted = localStorage.getItem(MESSAGES_PANEL_MODE_STORAGE_KEY);
return VALID_MESSAGES_PANEL_MODES.has(persisted as TeamMessagesPanelMode)
? (persisted as TeamMessagesPanelMode)
: DEFAULT_MESSAGES_PANEL_MODE;
} catch {
return DEFAULT_MESSAGES_PANEL_MODE;
}
}
export function savePersistedMessagesPanelMode(mode: TeamMessagesPanelMode): void {
try {
localStorage.setItem(MESSAGES_PANEL_MODE_STORAGE_KEY, mode);
} catch {
// ignore - best-effort UI preference persistence
}
}

View file

@ -0,0 +1,45 @@
const activeTeamPendingReplyWaitSourceIdsByTeam = new Map<string, Set<string>>();
export function hasActiveTeamPendingReplyWait(teamName: string): boolean {
return (activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName)?.size ?? 0) > 0;
}
export function getActiveTeamPendingReplyWaits(): Set<string> {
return new Set(
Array.from(activeTeamPendingReplyWaitSourceIdsByTeam.entries())
.filter(([, sourceIds]) => sourceIds.size > 0)
.map(([teamName]) => teamName)
);
}
export function clearAllPendingReplyRefreshWaits(): void {
activeTeamPendingReplyWaitSourceIdsByTeam.clear();
}
export function clearPendingReplyRefreshWaits(teamName: string): void {
activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName);
}
export function setPendingReplyRefreshEnabled(
teamName: string,
sourceId: string,
enabled: boolean
): boolean {
if (enabled) {
const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName) ?? new Set<string>();
existing.add(sourceId);
activeTeamPendingReplyWaitSourceIdsByTeam.set(teamName, existing);
return true;
}
const existing = activeTeamPendingReplyWaitSourceIdsByTeam.get(teamName);
if (!existing) {
return false;
}
existing.delete(sourceId);
if (existing.size === 0) {
activeTeamPendingReplyWaitSourceIdsByTeam.delete(teamName);
return false;
}
return true;
}

View file

@ -0,0 +1,44 @@
import type { TeamProvisioningProgress } from '@shared/types';
type TeamProvisioningProgressState = TeamProvisioningProgress['state'];
const ACTIVE_PROVISIONING_STATES: ReadonlySet<TeamProvisioningProgressState> = new Set([
'validating',
'spawning',
'configuring',
'assembling',
'finalizing',
'verifying',
]);
const TERMINAL_PROVISIONING_STATES: ReadonlySet<TeamProvisioningProgressState> = new Set([
'ready',
'failed',
'disconnected',
'cancelled',
]);
export function isActiveProvisioningState(state: TeamProvisioningProgressState): boolean {
return ACTIVE_PROVISIONING_STATES.has(state);
}
export function isTerminalProvisioningState(state: TeamProvisioningProgressState): boolean {
return TERMINAL_PROVISIONING_STATES.has(state);
}
export function shouldIgnoreProvisioningProgressRegression(
currentState: TeamProvisioningProgressState,
nextState: TeamProvisioningProgressState
): boolean {
if (currentState === 'ready') {
return nextState !== 'ready' && nextState !== 'disconnected';
}
if (
currentState === 'failed' ||
currentState === 'cancelled' ||
currentState === 'disconnected'
) {
return nextState !== currentState;
}
return false;
}

View file

@ -0,0 +1,48 @@
interface TeamRefreshBurstDiagnostic {
windowStartedAt: number;
count: number;
lastWarnAt: number;
}
const teamRefreshBurstDiagnostics = new Map<string, TeamRefreshBurstDiagnostic>();
export function hasTeamRefreshBurstDiagnostics(teamName: string): boolean {
return teamRefreshBurstDiagnostics.has(teamName);
}
export function getTeamRefreshBurstDiagnosticForTests(
teamName: string
): TeamRefreshBurstDiagnostic | undefined {
const diagnostic = teamRefreshBurstDiagnostics.get(teamName);
return diagnostic ? { ...diagnostic } : undefined;
}
export function noteTeamRefreshBurst(
teamName: string,
windowMs: number,
now = Date.now()
): number {
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
windowStartedAt: now,
count: 0,
lastWarnAt: 0,
};
if (now - diagnostic.windowStartedAt > windowMs) {
diagnostic.windowStartedAt = now;
diagnostic.count = 0;
}
diagnostic.count += 1;
teamRefreshBurstDiagnostics.set(teamName, diagnostic);
return diagnostic.count;
}
export function clearTeamRefreshBurstDiagnostics(teamName: string): void {
teamRefreshBurstDiagnostics.delete(teamName);
}
export function clearAllTeamRefreshBurstDiagnostics(): void {
teamRefreshBurstDiagnostics.clear();
}

View file

@ -0,0 +1,533 @@
import { getMemberColorByName } from '@shared/constants/memberColors';
import { isLeadMember } from '@shared/utils/leadDetection';
import {
getTeamTaskWorkflowColumn,
isTeamTaskFinalForCompletionNotification,
} from '@shared/utils/teamTaskState';
import { selectTeamDataForName, type TeamDataSelectorState } from './teamDataSelectors';
import type {
MemberActivityMetaEntry,
ResolvedTeamMember,
TeamMemberActivityMeta,
TeamMemberSnapshot,
TeamSummary,
TeamViewSnapshot,
} from '@shared/types';
export interface ResolvedMemberSelectorState extends TeamDataSelectorState {
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
teamByName?: Record<string, TeamSummary>;
}
export interface ResolvedMemberSelectorCacheSnapshot {
hasResolvedMembersSelector: boolean;
resolvedMemberSelectorCount: number;
}
const resolvedMembersSelectorCache = new Map<
string,
{
snapshotRef: TeamViewSnapshot['members'];
configMembersRef: TeamViewSnapshot['config']['members'] | undefined;
summaryRef: TeamSummary | undefined;
tasksRef: TeamViewSnapshot['tasks'] | undefined;
metaMembersRef: TeamMemberActivityMeta['members'] | undefined;
result: ResolvedTeamMember[];
}
>();
const resolvedMemberSelectorCache = new Map<
string,
{
snapshotMemberRef: TeamMemberSnapshot | undefined;
metaEntryRef: MemberActivityMetaEntry | undefined;
result: ResolvedTeamMember | null;
}
>();
export function clearResolvedMemberSelectorCaches(): void {
resolvedMembersSelectorCache.clear();
resolvedMemberSelectorCache.clear();
}
export function clearResolvedMemberSelectorCachesForTeam(teamName: string): void {
resolvedMembersSelectorCache.delete(teamName);
const teamScopedPrefix = `${teamName}:`;
for (const key of resolvedMemberSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
resolvedMemberSelectorCache.delete(key);
}
}
}
export function getResolvedMemberSelectorCacheSnapshotForTeam(
teamName: string
): ResolvedMemberSelectorCacheSnapshot {
const teamScopedPrefix = `${teamName}:`;
let resolvedMemberSelectorCount = 0;
for (const key of resolvedMemberSelectorCache.keys()) {
if (key.startsWith(teamScopedPrefix)) {
resolvedMemberSelectorCount += 1;
}
}
return {
hasResolvedMembersSelector: resolvedMembersSelectorCache.has(teamName),
resolvedMemberSelectorCount,
};
}
function resolveMemberStatus(
snapshot: TeamMemberSnapshot,
activity: MemberActivityMetaEntry | undefined
): ResolvedTeamMember['status'] {
if (activity?.latestAuthoredMessageSignalsTermination) {
return 'terminated';
}
if (!activity?.lastAuthoredMessageAt) {
return snapshot.currentTaskId ? 'active' : 'idle';
}
const ageMs = Date.now() - Date.parse(activity.lastAuthoredMessageAt);
if (Number.isNaN(ageMs)) {
return 'unknown';
}
if (ageMs < 5 * 60 * 1000) {
return 'active';
}
return 'idle';
}
function buildResolvedMembers(
snapshots: readonly TeamMemberSnapshot[],
meta: TeamMemberActivityMeta | undefined
): ResolvedTeamMember[] {
return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name]));
}
function isDisplayableFallbackCurrentTask(task: TeamViewSnapshot['tasks'][number]): boolean {
return (
task.status === 'in_progress' &&
getTeamTaskWorkflowColumn(task) !== 'review' &&
!isTeamTaskFinalForCompletionNotification(task)
);
}
function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMemberSnapshot[] {
const configMembers = snapshot.config.members ?? [];
const hasConfiguredTeammate = configMembers.some((member) => {
const name = member.name?.trim();
return Boolean(name) && !member.removedAt && !isLeadMember(member);
});
if (!hasConfiguredTeammate) {
return [];
}
const seenNames = new Set<string>();
const fallbackMembers: TeamMemberSnapshot[] = [];
for (const member of configMembers) {
const name = member.name?.trim();
if (!name) continue;
const key = name.toLowerCase();
if (seenNames.has(key)) continue;
seenNames.add(key);
const ownedTasks = snapshot.tasks.filter((task) => task.owner === name);
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
fallbackMembers.push({
name,
agentId: member.agentId,
currentTaskId: currentTask?.id ?? null,
taskCount: ownedTasks.length,
color: member.color ?? getMemberColorByName(name),
agentType: member.agentType,
role: member.role,
workflow: member.workflow,
isolation: member.isolation,
providerId: member.providerId,
providerBackendId: member.providerBackendId,
model: member.model,
effort: member.effort,
mcpPolicy: member.mcpPolicy,
selectedFastMode: member.fastMode,
cwd: member.cwd,
removedAt: member.removedAt,
});
}
return fallbackMembers;
}
function getActiveRawTeammateNameKeys(snapshot: TeamViewSnapshot | null | undefined): string[] {
if (!snapshot) {
return [];
}
const names = new Set<string>();
for (const member of snapshot.members) {
const name = member.name.trim();
const key = name.toLowerCase();
if (!name || key === 'user' || member.removedAt || isLeadMember(member)) {
continue;
}
names.add(key);
}
return Array.from(names).sort((left, right) => left.localeCompare(right));
}
function hasActiveRawTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return getActiveRawTeammateNameKeys(snapshot).length > 0;
}
function hasRemovedRawMemberRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return Boolean(snapshot?.members.some((member) => member.removedAt));
}
function hasConfigTeammateRoster(snapshot: TeamViewSnapshot | null | undefined): boolean {
return Boolean(
snapshot?.config.members?.some((member) => {
const name = member.name?.trim();
return Boolean(name) && !member.removedAt && !isLeadMember(member);
})
);
}
interface SummaryFallbackMemberSource {
name: string;
agentId?: string;
role?: string;
color?: string;
mcpPolicy?: TeamMemberSnapshot['mcpPolicy'];
}
function normalizeSummaryTeammateName(
name: string | undefined | null,
leadName?: string
): string | null {
const trimmed = name?.trim();
const normalizedName = trimmed?.toLowerCase();
const normalizedLeadName = leadName?.trim().toLowerCase();
if (
!trimmed ||
normalizedName === 'user' ||
isLeadMember({ name: trimmed }) ||
(normalizedLeadName && normalizedName === normalizedLeadName)
) {
return null;
}
return trimmed;
}
function getSummaryRosterTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
const seenNames = new Set<string>();
const sources: SummaryFallbackMemberSource[] = [];
for (const member of summary.members ?? []) {
const name = normalizeSummaryTeammateName(member.name, summary.leadName);
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seenNames.has(key)) {
continue;
}
seenNames.add(key);
sources.push({
name,
agentId: member.agentId,
role: member.role,
color: member.color,
mcpPolicy: member.mcpPolicy,
});
}
return sources;
}
function shouldUseSummaryLaunchTeammateSources(summary: TeamSummary): boolean {
return (
summary.partialLaunchFailure === true ||
summary.teamLaunchState === 'partial_failure' ||
summary.teamLaunchState === 'partial_pending' ||
summary.teamLaunchState === 'partial_skipped'
);
}
function getSummaryLaunchTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
if (!shouldUseSummaryLaunchTeammateSources(summary)) {
return [];
}
const seenNames = new Set<string>();
const sources: SummaryFallbackMemberSource[] = [];
for (const rawName of [...(summary.missingMembers ?? []), ...(summary.skippedMembers ?? [])]) {
const name = normalizeSummaryTeammateName(rawName, summary.leadName);
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seenNames.has(key)) {
continue;
}
seenNames.add(key);
sources.push({ name });
}
return sources;
}
function getSummaryLaunchTeammateNameKeys(summary: TeamSummary): string[] {
return getSummaryLaunchTeammateSources(summary)
.map((member) => member.name.toLowerCase())
.sort((left, right) => left.localeCompare(right));
}
function getSummaryTeammateNameKeys(summary: TeamSummary): string[] {
const rosterNames = getSummaryRosterTeammateSources(summary)
.map((member) => member.name.toLowerCase())
.sort((left, right) => left.localeCompare(right));
if (rosterNames.length > 0) {
return rosterNames;
}
const launchNames = getSummaryLaunchTeammateNameKeys(summary);
const expectedCount = summary.expectedMemberCount ?? summary.memberCount;
if (expectedCount > 0 && launchNames.length === expectedCount) {
return launchNames;
}
return [];
}
function getSummaryFallbackTeammateSources(summary: TeamSummary): SummaryFallbackMemberSource[] {
return getSummaryRosterTeammateSources(summary);
}
function areNameKeyListsEqual(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((name, index) => name === right[index]);
}
function summaryConfirmsActiveTeammateRoster(
current: TeamViewSnapshot,
summary: TeamSummary
): boolean {
if ((summary.expectedMemberCount ?? summary.memberCount) <= 0) {
return false;
}
const currentNames = getActiveRawTeammateNameKeys(current);
const summaryNames = getSummaryTeammateNameKeys(summary);
if (summaryNames.length === 0 || summaryNames.length !== currentNames.length) {
return false;
}
return areNameKeyListsEqual(summaryNames, currentNames);
}
function buildSummaryFallbackMemberSnapshots(
snapshot: TeamViewSnapshot,
summary: TeamSummary | undefined
): TeamMemberSnapshot[] {
if (!summary) {
return [];
}
const summaryMembers = getSummaryFallbackTeammateSources(summary);
if (summaryMembers.length === 0) {
return [];
}
const seenNames = new Set<string>();
const buildSnapshot = (
name: string,
source?: Omit<SummaryFallbackMemberSource, 'name'>,
lead = false
): TeamMemberSnapshot | null => {
const trimmed = name.trim();
if (!trimmed) return null;
const key = trimmed.toLowerCase();
if (seenNames.has(key)) return null;
seenNames.add(key);
const ownedTasks = snapshot.tasks.filter((task) => task.owner === trimmed);
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
return {
name: trimmed,
agentId: source?.agentId,
currentTaskId: currentTask?.id ?? null,
taskCount: ownedTasks.length,
color: source?.color ?? getMemberColorByName(trimmed),
agentType: lead ? 'team-lead' : undefined,
role: source?.role ?? (lead ? 'Team Lead' : undefined),
mcpPolicy: source?.mcpPolicy,
};
};
const teammates = summaryMembers.flatMap((member) => {
const item = buildSnapshot(member.name, member);
return item ? [item] : [];
});
if (teammates.length === 0) {
return [];
}
const existingLead = snapshot.members.find((member) => !member.removedAt && isLeadMember(member));
if (existingLead) {
return [existingLead, ...teammates];
}
const configuredLead = snapshot.config.members?.find(
(member) => !member.removedAt && isLeadMember(member)
);
const leadName = configuredLead?.name?.trim() || summary.leadName?.trim();
const lead = leadName
? buildSnapshot(
leadName,
{
agentId: configuredLead?.agentId,
role: configuredLead?.role,
color: configuredLead?.color ?? summary.leadColor,
},
true
)
: null;
return lead ? [lead, ...teammates] : teammates;
}
function getResolvableMemberSnapshots(
snapshot: TeamViewSnapshot,
summary?: TeamSummary
): readonly TeamMemberSnapshot[] {
if (
snapshot.members.length > 0 &&
(hasActiveRawTeammateRoster(snapshot) || hasRemovedRawMemberRoster(snapshot))
) {
return snapshot.members;
}
const configFallbackMembers = buildConfigFallbackMemberSnapshots(snapshot);
if (configFallbackMembers.length > 0) {
return configFallbackMembers;
}
const summaryFallbackMembers = buildSummaryFallbackMemberSnapshots(snapshot, summary);
if (summaryFallbackMembers.length > 0) {
return summaryFallbackMembers;
}
return snapshot.members;
}
export function shouldPreserveSelectedTeamSnapshot(
current: TeamViewSnapshot | null,
baseline: TeamViewSnapshot | null | undefined,
incoming: TeamViewSnapshot,
summary: TeamSummary | undefined
): boolean {
if (!current || !hasActiveRawTeammateRoster(current)) {
return false;
}
if (
hasActiveRawTeammateRoster(incoming) ||
hasRemovedRawMemberRoster(incoming) ||
hasConfigTeammateRoster(incoming)
) {
return false;
}
const currentNames = getActiveRawTeammateNameKeys(current);
if (
current !== baseline &&
!areNameKeyListsEqual(currentNames, getActiveRawTeammateNameKeys(baseline))
) {
return true;
}
if (summary) {
return summaryConfirmsActiveTeammateRoster(current, summary);
}
return false;
}
function buildResolvedMember(
snapshot: TeamMemberSnapshot,
activity: MemberActivityMetaEntry | undefined
): ResolvedTeamMember {
return {
...snapshot,
status: resolveMemberStatus(snapshot, activity),
messageCount: activity?.messageCountExact ?? 0,
lastActiveAt: activity?.lastAuthoredMessageAt ?? null,
};
}
export function selectResolvedMembersForTeamName(
state: ResolvedMemberSelectorState,
teamName: string | null | undefined
): ResolvedTeamMember[] {
const snapshot = selectTeamDataForName(state, teamName);
if (!snapshot || !teamName) {
return [];
}
const meta = state.memberActivityMetaByTeam[teamName];
const metaMembers = meta?.members;
const shouldUseMemberFallback =
snapshot.members.length === 0 ||
(!hasActiveRawTeammateRoster(snapshot) && !hasRemovedRawMemberRoster(snapshot));
const configMembersRef = shouldUseMemberFallback ? snapshot.config.members : undefined;
const summaryRef = shouldUseMemberFallback ? state.teamByName?.[teamName] : undefined;
const tasksRef = shouldUseMemberFallback ? snapshot.tasks : undefined;
const cached = resolvedMembersSelectorCache.get(teamName);
if (
cached?.snapshotRef === snapshot.members &&
cached.configMembersRef === configMembersRef &&
cached.summaryRef === summaryRef &&
cached.tasksRef === tasksRef &&
cached.metaMembersRef === metaMembers
) {
return cached.result;
}
const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot, summaryRef), meta);
resolvedMembersSelectorCache.set(teamName, {
snapshotRef: snapshot.members,
configMembersRef,
summaryRef,
tasksRef,
metaMembersRef: metaMembers,
result,
});
return result;
}
export function selectResolvedMemberForTeamName(
state: ResolvedMemberSelectorState,
teamName: string | null | undefined,
memberName: string | null | undefined
): ResolvedTeamMember | null {
const snapshot = selectTeamDataForName(state, teamName);
if (!snapshot || !teamName || !memberName) {
return null;
}
const snapshotMember = getResolvableMemberSnapshots(snapshot, state.teamByName?.[teamName]).find(
(member) => member.name === memberName
);
if (!snapshotMember) {
return null;
}
const metaEntry = state.memberActivityMetaByTeam[teamName]?.members[memberName];
const cacheKey = `${teamName}:${memberName}`;
const cached = resolvedMemberSelectorCache.get(cacheKey);
if (cached?.snapshotMemberRef === snapshotMember && cached.metaEntryRef === metaEntry) {
return cached.result;
}
const result = buildResolvedMember(snapshotMember, metaEntry);
resolvedMemberSelectorCache.set(cacheKey, {
snapshotMemberRef: snapshotMember,
metaEntryRef: metaEntry,
result,
});
return result;
}

View file

@ -0,0 +1,190 @@
interface TeamMessagesLoadingEntry {
loadingHead: boolean;
loadingOlder: boolean;
}
interface TeamScopedVisibleLoadingResetState<
TTeamMessagesEntry extends TeamMessagesLoadingEntry,
> {
teamMessagesByName: Record<string, TTeamMessagesEntry>;
selectedTeamName: string | null;
selectedTeamLoading: boolean;
selectedTeamError: string | null;
}
interface TeamScopedProvisioningRun {
teamName: string;
}
type TeamScopedRecord = Record<string, unknown>;
interface TeamScopedStateRemovalState<
TProvisioningRun extends TeamScopedProvisioningRun = TeamScopedProvisioningRun,
> {
provisioningRuns: Record<string, TProvisioningRun>;
teamDataCacheByName: TeamScopedRecord;
teamAgentRuntimeByTeam: TeamScopedRecord;
teamMessagesByName: TeamScopedRecord;
memberActivityMetaByTeam: TeamScopedRecord;
provisioningSnapshotByTeam: TeamScopedRecord;
currentProvisioningRunIdByTeam: TeamScopedRecord;
currentRuntimeRunIdByTeam: TeamScopedRecord;
provisioningStartedAtFloorByTeam: TeamScopedRecord;
leadActivityByTeam: TeamScopedRecord;
leadContextByTeam: TeamScopedRecord;
activeTaskLogActivityByTeam: TeamScopedRecord;
activeToolsByTeam: TeamScopedRecord;
finishedVisibleByTeam: TeamScopedRecord;
toolHistoryByTeam: TeamScopedRecord;
memberSpawnStatusesByTeam: TeamScopedRecord;
memberSpawnSnapshotsByTeam: TeamScopedRecord;
provisioningErrorByTeam: TeamScopedRecord;
}
type TeamScopedStateRemovalKey = keyof TeamScopedStateRemovalState;
interface TeamScopedProgressTombstoneState {
currentProvisioningRunIdByTeam: Record<string, string | null | undefined>;
currentRuntimeRunIdByTeam: Record<string, string | null | undefined>;
ignoredProvisioningRunIds: Record<string, string>;
ignoredRuntimeRunIds: Record<string, string>;
provisioningStartedAtFloorByTeam: Record<string, string>;
}
export function collectTeamScopedVisibleLoadingResets<
TTeamMessagesEntry extends TeamMessagesLoadingEntry,
>(
state: TeamScopedVisibleLoadingResetState<TTeamMessagesEntry>,
teamName: string
): Partial<TeamScopedVisibleLoadingResetState<TTeamMessagesEntry>> {
const nextTeamMessagesEntry = state.teamMessagesByName[teamName];
const nextTeamMessagesByName =
nextTeamMessagesEntry &&
(nextTeamMessagesEntry.loadingHead || nextTeamMessagesEntry.loadingOlder)
? {
...state.teamMessagesByName,
[teamName]: {
...nextTeamMessagesEntry,
loadingHead: false,
loadingOlder: false,
} as TTeamMessagesEntry,
}
: null;
const shouldResetSelectedSurface =
state.selectedTeamName === teamName &&
(state.selectedTeamLoading || state.selectedTeamError != null);
return {
...(nextTeamMessagesByName ? { teamMessagesByName: nextTeamMessagesByName } : {}),
...(shouldResetSelectedSurface
? {
selectedTeamLoading: false,
selectedTeamError: null,
}
: {}),
};
}
function omitTeamKey<TRecord extends Record<string, unknown>>(
record: TRecord,
teamName: string
): TRecord | null {
if (!(teamName in record)) {
return null;
}
const next = { ...record };
delete next[teamName];
return next;
}
export function collectTeamScopedStateRemovals<TState extends TeamScopedStateRemovalState>(
state: TState,
teamName: string
): Partial<Pick<TState, TeamScopedStateRemovalKey>> {
const nextProvisioningRuns = Object.fromEntries(
Object.entries(state.provisioningRuns).filter(([, run]) => run.teamName !== teamName)
) as TState['provisioningRuns'];
const nextTeamDataCache = omitTeamKey(state.teamDataCacheByName, teamName);
const nextTeamAgentRuntime = omitTeamKey(state.teamAgentRuntimeByTeam, teamName);
const nextTeamMessages = omitTeamKey(state.teamMessagesByName, teamName);
const nextMemberActivityMeta = omitTeamKey(state.memberActivityMetaByTeam, teamName);
const nextProvisioningSnapshot = omitTeamKey(state.provisioningSnapshotByTeam, teamName);
const nextCurrentProvisioningRunId = omitTeamKey(state.currentProvisioningRunIdByTeam, teamName);
const nextCurrentRuntimeRunId = omitTeamKey(state.currentRuntimeRunIdByTeam, teamName);
const nextProvisioningStartedAtFloor = omitTeamKey(
state.provisioningStartedAtFloorByTeam,
teamName
);
const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName);
const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName);
const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName);
const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName);
const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName);
const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName);
const nextMemberSpawnStatuses = omitTeamKey(state.memberSpawnStatusesByTeam, teamName);
const nextMemberSpawnSnapshots = omitTeamKey(state.memberSpawnSnapshotsByTeam, teamName);
const nextProvisioningErrors = omitTeamKey(state.provisioningErrorByTeam, teamName);
return {
...(Object.keys(nextProvisioningRuns).length !== Object.keys(state.provisioningRuns).length
? { provisioningRuns: nextProvisioningRuns }
: {}),
...(nextTeamDataCache ? { teamDataCacheByName: nextTeamDataCache } : {}),
...(nextTeamAgentRuntime ? { teamAgentRuntimeByTeam: nextTeamAgentRuntime } : {}),
...(nextTeamMessages ? { teamMessagesByName: nextTeamMessages } : {}),
...(nextMemberActivityMeta ? { memberActivityMetaByTeam: nextMemberActivityMeta } : {}),
...(nextProvisioningSnapshot ? { provisioningSnapshotByTeam: nextProvisioningSnapshot } : {}),
...(nextCurrentProvisioningRunId
? { currentProvisioningRunIdByTeam: nextCurrentProvisioningRunId }
: {}),
...(nextCurrentRuntimeRunId ? { currentRuntimeRunIdByTeam: nextCurrentRuntimeRunId } : {}),
...(nextProvisioningStartedAtFloor
? { provisioningStartedAtFloorByTeam: nextProvisioningStartedAtFloor }
: {}),
...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}),
...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}),
...(nextActiveTaskLogActivity
? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity }
: {}),
...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}),
...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}),
...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}),
...(nextMemberSpawnStatuses ? { memberSpawnStatusesByTeam: nextMemberSpawnStatuses } : {}),
...(nextMemberSpawnSnapshots ? { memberSpawnSnapshotsByTeam: nextMemberSpawnSnapshots } : {}),
...(nextProvisioningErrors ? { provisioningErrorByTeam: nextProvisioningErrors } : {}),
};
}
export function buildTeamScopedProgressTombstones<TState extends TeamScopedProgressTombstoneState>(
state: TState,
teamName: string,
floor: string
): Pick<
TState,
'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam'
> {
const nextIgnoredProvisioningRunIds = { ...state.ignoredProvisioningRunIds };
const nextIgnoredRuntimeRunIds = { ...state.ignoredRuntimeRunIds };
const currentProvisioningRunId = state.currentProvisioningRunIdByTeam[teamName];
const currentRuntimeRunId = state.currentRuntimeRunIdByTeam[teamName];
if (currentProvisioningRunId) {
nextIgnoredProvisioningRunIds[currentProvisioningRunId] = teamName;
}
if (currentRuntimeRunId) {
nextIgnoredRuntimeRunIds[currentRuntimeRunId] = teamName;
}
return {
ignoredProvisioningRunIds: nextIgnoredProvisioningRunIds,
ignoredRuntimeRunIds: nextIgnoredRuntimeRunIds,
provisioningStartedAtFloorByTeam: {
...state.provisioningStartedAtFloorByTeam,
[teamName]: floor,
},
} as Pick<
TState,
'ignoredProvisioningRunIds' | 'ignoredRuntimeRunIds' | 'provisioningStartedAtFloorByTeam'
>;
}

View file

@ -0,0 +1,61 @@
import type { TeamViewSnapshot } from '@shared/types';
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value == null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
export function structurallySharePlainValue<T>(previous: T, next: T): T {
if (Object.is(previous, next)) {
return previous;
}
if (Array.isArray(previous) && Array.isArray(next)) {
let changed = previous.length !== next.length;
const result = next.map((nextItem, index) => {
const sharedItem = structurallySharePlainValue(previous[index], nextItem);
if (!Object.is(sharedItem, previous[index])) {
changed = true;
}
return sharedItem;
});
return changed ? (result as T) : previous;
}
if (isPlainObject(previous) && isPlainObject(next)) {
const previousRecord = previous as Record<string, unknown>;
const nextRecord = next as Record<string, unknown>;
const previousKeys = Object.keys(previousRecord);
const nextKeys = Object.keys(nextRecord);
let changed = previousKeys.length !== nextKeys.length;
const result: Record<string, unknown> = {};
for (const key of nextKeys) {
if (!Object.prototype.hasOwnProperty.call(previousRecord, key)) {
changed = true;
}
const sharedValue = structurallySharePlainValue(previousRecord[key], nextRecord[key]);
if (!Object.is(sharedValue, previousRecord[key])) {
changed = true;
}
result[key] = sharedValue;
}
return changed ? (result as T) : previous;
}
return next;
}
export function structurallyShareTeamSnapshot(
previous: TeamViewSnapshot | null | undefined,
next: TeamViewSnapshot
): TeamViewSnapshot {
if (!previous) {
return next;
}
return structurallySharePlainValue(previous, next);
}

View file

@ -0,0 +1,42 @@
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import type { ToolApprovalSettings } from '@shared/types';
const VALID_TIMEOUT_ACTIONS: ReadonlySet<ToolApprovalSettings['timeoutAction']> = new Set([
'allow',
'deny',
'wait',
]);
export function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings {
if (!raw) return DEFAULT_TOOL_APPROVAL_SETTINGS;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const d = DEFAULT_TOOL_APPROVAL_SETTINGS;
return {
autoAllowAll: typeof parsed.autoAllowAll === 'boolean' ? parsed.autoAllowAll : d.autoAllowAll,
autoAllowFileEdits:
typeof parsed.autoAllowFileEdits === 'boolean'
? parsed.autoAllowFileEdits
: d.autoAllowFileEdits,
autoAllowSafeBash:
typeof parsed.autoAllowSafeBash === 'boolean'
? parsed.autoAllowSafeBash
: d.autoAllowSafeBash,
timeoutAction:
typeof parsed.timeoutAction === 'string' &&
VALID_TIMEOUT_ACTIONS.has(parsed.timeoutAction as ToolApprovalSettings['timeoutAction'])
? (parsed.timeoutAction as ToolApprovalSettings['timeoutAction'])
: d.timeoutAction,
timeoutSeconds:
typeof parsed.timeoutSeconds === 'number' &&
Number.isFinite(parsed.timeoutSeconds) &&
parsed.timeoutSeconds >= 5 &&
parsed.timeoutSeconds <= 300
? parsed.timeoutSeconds
: d.timeoutSeconds,
};
} catch {
return DEFAULT_TOOL_APPROVAL_SETTINGS;
}
}

View file

@ -2,12 +2,118 @@ import { toMessageKey } from './teamMessageKey';
import type { InboxMessage } from '@shared/types'; import type { InboxMessage } from '@shared/types';
const MAX_LEAD_FRAGMENT_GAP_MS = 2_000;
const MAX_LEAD_FRAGMENT_AVG_LENGTH = 14;
const MIN_LEAD_FRAGMENT_RUN_LENGTH = 3;
function compareMessages(a: InboxMessage, b: InboxMessage): number { function compareMessages(a: InboxMessage, b: InboxMessage): number {
const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp); const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp);
if (diff !== 0) return diff; if (diff !== 0) return diff;
return toMessageKey(a).localeCompare(toMessageKey(b)); return toMessageKey(a).localeCompare(toMessageKey(b));
} }
function isLeadThoughtFragmentCandidate(message: InboxMessage): boolean {
if (typeof message.to === 'string' && message.to.trim().length > 0) {
return false;
}
if (message.messageKind || message.toolCalls?.length || message.toolSummary) {
return false;
}
return message.source === 'lead_process' || message.source === 'lead_session';
}
function canJoinLeadThoughtFragments(older: InboxMessage, newer: InboxMessage): boolean {
if (!isLeadThoughtFragmentCandidate(older) || !isLeadThoughtFragmentCandidate(newer)) {
return false;
}
if (older.from !== newer.from) {
return false;
}
if ((older.leadSessionId ?? null) !== (newer.leadSessionId ?? null)) {
return false;
}
if (older.source !== newer.source) {
return false;
}
const olderMs = Date.parse(older.timestamp);
const newerMs = Date.parse(newer.timestamp);
if (!Number.isFinite(olderMs) || !Number.isFinite(newerMs)) {
return false;
}
return newerMs >= olderMs && newerMs - olderMs <= MAX_LEAD_FRAGMENT_GAP_MS;
}
function shouldCoalesceLeadThoughtRun(runNewestFirst: InboxMessage[]): boolean {
if (runNewestFirst.length < MIN_LEAD_FRAGMENT_RUN_LENGTH) {
return false;
}
const totalTrimmedLength = runNewestFirst.reduce(
(total, message) => total + message.text.trim().length,
0
);
return totalTrimmedLength / runNewestFirst.length <= MAX_LEAD_FRAGMENT_AVG_LENGTH;
}
function coalesceLeadThoughtRun(runNewestFirst: InboxMessage[]): InboxMessage[] {
if (!shouldCoalesceLeadThoughtRun(runNewestFirst)) {
return runNewestFirst;
}
const chronological = [...runNewestFirst].reverse();
const combinedText = chronological
.map((message) => message.text)
.join('')
.trim();
if (!combinedText) {
return runNewestFirst;
}
const newest = runNewestFirst[0];
const oldest = chronological[0];
return [
{
...newest,
text: combinedText,
summary: combinedText.length > 60 ? `${combinedText.slice(0, 57)}...` : combinedText,
messageId: `lead-thought-coalesced-${toMessageKey(oldest)}-${runNewestFirst.length}`,
},
];
}
function coalesceLeadThoughtFragments(messagesNewestFirst: InboxMessage[]): InboxMessage[] {
const result: InboxMessage[] = [];
let run: InboxMessage[] = [];
const flushRun = (): void => {
if (run.length === 0) return;
result.push(...coalesceLeadThoughtRun(run));
run = [];
};
for (const message of messagesNewestFirst) {
if (!isLeadThoughtFragmentCandidate(message)) {
flushRun();
result.push(message);
continue;
}
const currentOldest = run[run.length - 1];
if (!currentOldest || canJoinLeadThoughtFragments(message, currentOldest)) {
run.push(message);
continue;
}
flushRun();
run.push(message);
}
flushRun();
return result;
}
/** /**
* Merge multiple message arrays into one newest-first list with stable deduplication. * Merge multiple message arrays into one newest-first list with stable deduplication.
* *
@ -23,5 +129,5 @@ export function mergeTeamMessages(...messageLists: readonly InboxMessage[][]): I
} }
} }
return Array.from(merged.values()).sort(compareMessages); return coalesceLeadThoughtFragments(Array.from(merged.values()).sort(compareMessages));
} }

View file

@ -806,6 +806,14 @@ export interface TelemetryAPI {
getSentryContext: () => Promise<SentryTelemetryContext | null>; getSentryContext: () => Promise<SentryTelemetryContext | null>;
} }
export interface WindowsElevationStatus {
platform: string;
isWindows: boolean;
isAdministrator: boolean | null;
checkFailed: boolean;
error: string | null;
}
// ============================================================================= // =============================================================================
// Main Electron API // Main Electron API
// ============================================================================= // =============================================================================
@ -817,6 +825,7 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
startup?: AppStartupAPI; startup?: AppStartupAPI;
telemetry: TelemetryAPI; telemetry: TelemetryAPI;
getAppVersion: () => Promise<string>; getAppVersion: () => Promise<string>;
getWindowsElevationStatus: () => Promise<WindowsElevationStatus>;
getProjects: () => Promise<Project[]>; getProjects: () => Promise<Project[]>;
getSessions: (projectId: string) => Promise<Session[]>; getSessions: (projectId: string) => Promise<Session[]>;
getSessionsPaginated: ( getSessionsPaginated: (

View file

@ -1500,12 +1500,24 @@ export interface TeamProvisioningPrepareIssue {
message: string; message: string;
} }
export interface TeamProvisioningSupportDiagnostic {
id: string;
providerId: TeamProviderId;
kind: string;
severity: 'info' | 'warning' | 'error';
title: string;
summary: string;
copyText: string;
createdAt: string;
}
export interface TeamProvisioningPrepareResult { export interface TeamProvisioningPrepareResult {
ready: boolean; ready: boolean;
message: string; message: string;
details?: string[]; details?: string[];
warnings?: string[]; warnings?: string[];
issues?: TeamProvisioningPrepareIssue[]; issues?: TeamProvisioningPrepareIssue[];
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
} }
export interface TeamProvisioningProgress { export interface TeamProvisioningProgress {
@ -1771,6 +1783,8 @@ export interface ToolApprovalRequest {
/** Run ID — prevents stale approvals after stop→launch race. */ /** Run ID — prevents stale approvals after stop→launch race. */
runId: string; runId: string;
teamName: string; teamName: string;
/** Runtime/provider that owns the approval, when it is not the Anthropic CLI control protocol. */
providerId?: TeamProviderId;
/** Which process sent this (e.g. 'lead'). */ /** Which process sent this (e.g. 'lead'). */
source: string; source: string;
/** Tool name: 'Bash', 'Edit', 'Write', 'Read', etc. */ /** Tool name: 'Bash', 'Edit', 'Write', 'Read', etc. */
@ -1783,6 +1797,14 @@ export interface ToolApprovalRequest {
teamColor?: string; teamColor?: string;
/** Team display name (from config or create request). */ /** Team display name (from config or create request). */
teamDisplayName?: string; teamDisplayName?: string;
/** Provider runtime permission metadata used to answer non-Anthropic approval APIs. */
runtimePermission?: {
providerId: 'anthropic' | 'opencode' | 'codex';
laneId: string;
memberName: string;
providerRequestId: string;
sessionId?: string | null;
};
/** Permission suggestions from teammate runtime (only for teammate permission_request). /** Permission suggestions from teammate runtime (only for teammate permission_request).
* FACT: Populated by Claude Code runtime, contains instructions to add permission rules. * FACT: Populated by Claude Code runtime, contains instructions to add permission rules.
*/ */
@ -1837,7 +1859,7 @@ export interface ToolApprovalAutoResolved {
requestId: string; requestId: string;
runId: string; runId: string;
teamName: string; teamName: string;
reason: 'auto_allow_category' | 'timeout_allow' | 'timeout_deny'; reason: 'auto_allow_category' | 'timeout_allow' | 'timeout_deny' | 'runtime_resolved';
} }
/** Union of approval events pushed from main to renderer. */ /** Union of approval events pushed from main to renderer. */

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
isOpenCodeWindowsAccessDeniedDiagnostic,
normalizeOpenCodeWindowsAccessDeniedDiagnostic,
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
} from '../openCodeWindowsAccessDenied';
describe('OpenCode Windows access-denied diagnostics', () => {
it.each([
'EPERM: operation not permitted, mkdir C:\\Program Files\\project',
'EACCES: permission denied, open C:\\work\\repo',
'Access is denied.',
'permission denied while opening OpenCode runtime file',
'operation not permitted while starting OpenCode',
])('detects %s', (message) => {
expect(isOpenCodeWindowsAccessDeniedDiagnostic(message)).toBe(true);
expect(normalizeOpenCodeWindowsAccessDeniedDiagnostic(message)).toBe(
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE
);
});
it('does not match unrelated OpenCode diagnostics', () => {
expect(isOpenCodeWindowsAccessDeniedDiagnostic('OpenCode app MCP is unreachable')).toBe(false);
expect(normalizeOpenCodeWindowsAccessDeniedDiagnostic('OpenCode CLI not found')).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show more