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"
)
for ALIAS_NAME in "${!STABLE_ALIASES[@]}"; do
VERSIONED_NAME="${STABLE_ALIASES[$ALIAS_NAME]}"
echo "Uploading stable alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}"
gh release download "${TAG}" \
--repo "$REPO" \
--pattern "${VERSIONED_NAME}" \
--dir "$TMP_DIR" \
--clobber
declare -A LEGACY_STABLE_ALIASES=(
["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg"
["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg"
["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe"
["Claude-Agent-Teams-UI.AppImage"]="Agent.Teams.AI-${VERSION}.AppImage"
["Claude-Agent-Teams-UI-amd64.deb"]="agent-teams-ai_${VERSION}_amd64.deb"
["Claude-Agent-Teams-UI-x86_64.rpm"]="agent-teams-ai-${VERSION}.x86_64.rpm"
["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman"
)
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}" \
--repo "$REPO" \
--pattern "${name}" \
--dir "$TMP_DIR" \
--clobber
}
upload_aliases "stable" STABLE_ALIASES
upload_aliases "legacy stable" LEGACY_STABLE_ALIASES
upload_aliases "legacy updater" LEGACY_UPDATER_ALIASES
- name: Publish canonical updater metadata
env:
@ -761,26 +797,37 @@ jobs:
EOF
# Canonical macOS feed.
# electron-updater on GitHub still consumes a single latest-mac.yml, so we
# publish the Apple Silicon feed here and suppress Intel auto-update in-app
# until we switch to universal packaging or an arch-aware provider.
# Include both architectures so legacy Intel builds can see the
# update without downloading the Apple Silicon zip.
download_asset "Agent.Teams.AI-${VERSION}-arm64-mac.zip"
download_asset "Agent.Teams.AI-${VERSION}-arm64.dmg"
MAC_ZIP_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64-mac.zip")"
MAC_ZIP_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64-mac.zip")"
MAC_DMG_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64.dmg")"
MAC_DMG_SIZE="$(file_size "Agent.Teams.AI-${VERSION}-arm64.dmg")"
download_asset "Agent.Teams.AI-${VERSION}-x64-mac.zip"
download_asset "Agent.Teams.AI-${VERSION}-x64.dmg"
MAC_ARM64_ZIP_SHA="$(sha512_base64 "Agent.Teams.AI-${VERSION}-arm64-mac.zip")"
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
version: ${VERSION}
files:
- url: Agent.Teams.AI-${VERSION}-arm64-mac.zip
sha512: ${MAC_ZIP_SHA}
size: ${MAC_ZIP_SIZE}
sha512: ${MAC_ARM64_ZIP_SHA}
size: ${MAC_ARM64_ZIP_SIZE}
- url: Agent.Teams.AI-${VERSION}-arm64.dmg
sha512: ${MAC_DMG_SHA}
size: ${MAC_DMG_SIZE}
sha512: ${MAC_ARM64_DMG_SHA}
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
sha512: ${MAC_ZIP_SHA}
sha512: ${MAC_ARM64_ZIP_SHA}
releaseDate: '${RELEASE_DATE}'
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" />
</a>
<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 align="center">
<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,
mdiMonitorDashboard,
} 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 localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value));
const localizedHeroReviewerFeatureCard = computed(() => getLocalizedHeroReviewerFeatureCard(locale.value));
const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:");
const icons = [
mdiRobotOutline,
@ -18,10 +32,74 @@ const icons = [
mdiShieldCheckOutline,
mdiMonitorDashboard,
] 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>
<template>
<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
v-for="(feature, index) in localizedHeroFeatureRail"

View file

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

View file

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

View file

@ -8,7 +8,7 @@ export type Screenshot = {
/**
* 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 })[] = [
{ 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/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/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 { 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 baseURL = process.env.NUXT_APP_BASE_URL || "/";
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const defaultSeoTitle = "Agent Teams - AI Agent Orchestration for Developers";
const defaultSeoDescription = "Free, open-source desktop app for AI agent teams. Start with a free model with no auth, then connect Claude, Codex, or OpenCode when you need more models.";
const defaultSeoImage = `${siteUrl.replace(/\/+$/, "")}/og-image-agent-teams-v5.png`;
@ -82,6 +85,13 @@ export default defineNuxtConfig({
},
nitro: {
compressPublicAssets: true,
publicAssets: [
{
baseURL: "/screenshots",
dir: resolve(repoRoot, "docs/screenshots"),
maxAge: 60 * 60 * 24 * 365
}
],
prerender: {
ignore: [
"/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",
"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",
"license": "AGPL-3.0",
"author": {

View file

@ -1,27 +1,27 @@
{
"version": "0.0.45",
"sourceRef": "v0.0.45",
"version": "0.0.46",
"sourceRef": "v0.0.46",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v2.1.0",
"releaseTag": "v2.1.1",
"assets": {
"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",
"binaryName": "claude-multimodel"
},
"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",
"binaryName": "claude-multimodel"
},
"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",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.45.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.46.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -193,6 +193,7 @@ export type RuntimeProviderManagementErrorCodeDto =
| 'unsupported-runtime'
| 'unsupported-action'
| 'runtime-missing'
| 'runtime-misconfigured'
| 'runtime-unhealthy'
| 'provider-missing'
| 'auth-required'
@ -201,10 +202,24 @@ export type RuntimeProviderManagementErrorCodeDto =
| 'model-test-failed'
| '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 {
code: RuntimeProviderManagementErrorCodeDto;
message: string;
recoverable: boolean;
diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null;
}
export interface RuntimeProviderManagementViewResponse {

View file

@ -16,6 +16,7 @@ import type {
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementConnectInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementErrorDto,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadModelsInput,
@ -32,6 +33,85 @@ import type {
import type { IpcMain } from 'electron';
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(
ipcMain: IpcMain,
@ -46,15 +126,12 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.loadView(input);
} 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 {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load providers',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
};
}
}
@ -69,15 +146,15 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.loadProviderDirectory(input);
} 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 {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider directory',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
};
}
}
@ -92,15 +169,15 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.loadSetupForm(input);
} 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 {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider setup form',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
};
}
}
@ -115,18 +192,15 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.connectProvider(input);
} catch (error) {
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider');
logger.error(
'Failed to connect runtime provider',
error instanceof Error ? error.name : error
getRuntimeProviderIpcConnectLogDetail(error)
);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'auth-failed',
message: 'Failed to connect provider',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('auth-failed', message),
};
}
}
@ -141,18 +215,15 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.connectWithApiKey(input);
} catch (error) {
const message = getRuntimeProviderIpcErrorMessage(error, 'Failed to connect provider');
logger.error(
'Failed to connect runtime provider',
error instanceof Error ? error.name : error
getRuntimeProviderIpcConnectLogDetail(error)
);
return {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'auth-failed',
message: 'Failed to connect provider',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('auth-failed', message),
};
}
}
@ -167,15 +238,12 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.forgetCredential(input);
} 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 {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'unsupported-action',
message: error instanceof Error ? error.message : 'Failed to forget provider',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('unsupported-action', message),
};
}
}
@ -190,15 +258,12 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.loadModels(input);
} 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 {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'runtime-unhealthy',
message: error instanceof Error ? error.message : 'Failed to load provider models',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('runtime-unhealthy', message),
};
}
}
@ -213,15 +278,12 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.testModel(input);
} 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 {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'model-test-failed',
message: error instanceof Error ? error.message : 'Failed to test model',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message),
};
}
}
@ -236,15 +298,12 @@ export function registerRuntimeProviderManagementIpc(
try {
return await feature.setDefaultModel(input);
} 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 {
schemaVersion: 1,
runtimeId: input.runtimeId,
error: {
code: 'model-test-failed',
message: error instanceof Error ? error.message : 'Failed to set default model',
recoverable: true,
},
error: createUnexpectedRuntimeProviderIpcError('model-test-failed', message),
};
}
}

View file

@ -13,6 +13,7 @@ import type {
RuntimeProviderDefaultScopeDto,
RuntimeProviderDirectoryEntryDto,
RuntimeProviderDirectoryFilterDto,
RuntimeProviderManagementErrorDiagnosticsDto,
RuntimeProviderManagementRuntimeId,
RuntimeProviderManagementViewDto,
RuntimeProviderModelDto,
@ -46,6 +47,7 @@ export interface RuntimeProviderManagementState {
directoryLoading: boolean;
directoryRefreshing: boolean;
directoryError: string | null;
directoryErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
directoryEntries: readonly RuntimeProviderDirectoryEntryDto[];
directoryTotalCount: number | null;
directoryNextCursor: string | null;
@ -56,7 +58,9 @@ export interface RuntimeProviderManagementState {
setupForm: RuntimeProviderSetupFormDto | null;
setupFormLoading: boolean;
setupFormError: string | null;
setupFormErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
setupSubmitError: string | null;
setupSubmitErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
setupMetadata: Readonly<Record<string, string>>;
apiKeyValue: string;
modelPickerProviderId: string | null;
@ -65,6 +69,7 @@ export interface RuntimeProviderManagementState {
models: readonly RuntimeProviderModelDto[];
modelsLoading: boolean;
modelsError: string | null;
modelsErrorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
selectedModelId: string | null;
testingModelIds: readonly string[];
savingDefaultModelId: string | null;
@ -72,6 +77,7 @@ export interface RuntimeProviderManagementState {
loading: boolean;
savingProviderId: string | null;
error: string | null;
errorDiagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null;
successMessage: string | null;
}
@ -219,6 +225,8 @@ export function useRuntimeProviderManagement(
const [directoryLoading, setDirectoryLoading] = useState(false);
const [directoryRefreshing, setDirectoryRefreshing] = useState(false);
const [directoryError, setDirectoryError] = useState<string | null>(null);
const [directoryErrorDiagnostics, setDirectoryErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [directoryEntries, setDirectoryEntries] = useState<
readonly RuntimeProviderDirectoryEntryDto[]
>([]);
@ -234,7 +242,11 @@ export function useRuntimeProviderManagement(
const [setupForm, setSetupForm] = useState<RuntimeProviderSetupFormDto | null>(null);
const [setupFormLoading, setSetupFormLoading] = useState(false);
const [setupFormError, setSetupFormError] = useState<string | null>(null);
const [setupFormErrorDiagnostics, setSetupFormErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [setupSubmitError, setSetupSubmitError] = useState<string | null>(null);
const [setupSubmitErrorDiagnostics, setSetupSubmitErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [setupMetadata, setSetupMetadata] = useState<Record<string, string>>({});
const [apiKeyValue, setApiKeyValue] = useState('');
const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null);
@ -245,6 +257,8 @@ export function useRuntimeProviderManagement(
const [models, setModels] = useState<readonly RuntimeProviderModelDto[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const [modelsErrorDiagnostics, setModelsErrorDiagnostics] =
useState<RuntimeProviderManagementErrorDiagnosticsDto | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [testingModelIds, setTestingModelIds] = useState<readonly string[]>([]);
const [savingDefaultModelId, setSavingDefaultModelId] = useState<string | null>(null);
@ -254,6 +268,8 @@ export function useRuntimeProviderManagement(
const [loading, setLoading] = useState(false);
const [savingProviderId, setSavingProviderId] = 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 viewLoadRequestSeq = useRef(0);
const directoryRequestSeq = useRef(0);
@ -296,6 +312,7 @@ export function useRuntimeProviderManagement(
setModels([]);
setModelsLoading(false);
setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null);
setModelResults({});
setTestingModelIds([]);
@ -313,6 +330,7 @@ export function useRuntimeProviderManagement(
setModels([]);
setModelsLoading(false);
setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null);
setModelResults({});
setTestingModelIds([]);
@ -323,24 +341,31 @@ export function useRuntimeProviderManagement(
setupFormRequestSeq.current += 1;
modelLoadRequestSeq.current += 1;
modelProbeGenerationRef.current += 1;
setDirectoryLoading(false);
setDirectoryRefreshing(false);
setDirectoryEntries([]);
setDirectoryTotalCount(null);
setDirectoryNextCursor(null);
setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectorySelectedProviderId(null);
setDirectoryLoaded(false);
setSetupForm(null);
setSetupFormLoading(false);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setActiveFormProviderId(null);
setApiKeyValue('');
setSetupMetadata({});
setModels([]);
setModelsLoading(false);
setModelsError(null);
setModelsErrorDiagnostics(null);
setSelectedModelId(null);
setTestingModelIds([]);
setSavingProviderId(null);
setSavingDefaultModelId(null);
setModelResults({});
setSuccessMessage(null);
@ -361,6 +386,7 @@ export function useRuntimeProviderManagement(
setLoading(true);
}
setError(null);
setErrorDiagnostics(null);
try {
const response = await api.runtimeProviderManagement.loadView({
runtimeId: options.runtimeId,
@ -374,6 +400,7 @@ export function useRuntimeProviderManagement(
setView(null);
}
setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
const nextView = response.view ?? null;
@ -392,6 +419,7 @@ export function useRuntimeProviderManagement(
setView(null);
}
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers');
setErrorDiagnostics(null);
} finally {
if (!silent && requestIsCurrent()) {
setLoading(false);
@ -434,6 +462,7 @@ export function useRuntimeProviderManagement(
setDirectoryLoading(true);
}
setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
try {
const response = await api.runtimeProviderManagement.loadProviderDirectory({
@ -450,6 +479,7 @@ export function useRuntimeProviderManagement(
}
if (response.error) {
setDirectoryError(response.error.message);
setDirectoryErrorDiagnostics(response.error.diagnostics ?? null);
if (
response.error.code === 'unsupported-action' ||
response.error.message.toLowerCase().includes('unknown command')
@ -461,6 +491,7 @@ export function useRuntimeProviderManagement(
const directory = response.directory;
if (!directory) {
setDirectoryError('Provider directory response was empty');
setDirectoryErrorDiagnostics(null);
return;
}
setDirectoryLoaded(true);
@ -474,6 +505,7 @@ export function useRuntimeProviderManagement(
setDirectoryError(
loadError instanceof Error ? loadError.message : 'Failed to load provider directory'
);
setDirectoryErrorDiagnostics(null);
}
} finally {
if (requestIsCurrent()) {
@ -495,23 +527,37 @@ export function useRuntimeProviderManagement(
useEffect(() => {
if (!options.enabled) {
viewLoadRequestSeq.current += 1;
directoryRequestSeq.current += 1;
setupFormRequestSeq.current += 1;
appliedInitialProviderRef.current = null;
setView(null);
setSelectedProviderId(null);
setProviderQuery('');
setLoading(false);
setSavingProviderId(null);
setSavingDefaultModelId(null);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
setDirectoryLoading(false);
setDirectoryRefreshing(false);
setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectoryEntries([]);
setDirectoryTotalCount(null);
setDirectoryNextCursor(null);
setDirectoryQuery('');
setDirectoryLoaded(false);
setDirectorySelectedProviderId(null);
setDirectorySupported(true);
setApiKeyValue('');
setSetupMetadata({});
setSetupForm(null);
setSetupFormLoading(false);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setActiveFormProviderId(null);
closeModelPickerState();
return;
@ -537,12 +583,20 @@ export function useRuntimeProviderManagement(
);
return () => window.clearTimeout(timeout);
}, [directoryLoaded, directoryQuery, directorySupported, loadDirectoryPage, options.enabled]);
}, [
currentProjectPath,
directoryLoaded,
directoryQuery,
directorySupported,
loadDirectoryPage,
options.enabled,
]);
useEffect(() => {
if (!options.enabled || !modelPickerProviderId) {
modelLoadRequestSeq.current += 1;
setModelsLoading(false);
setModelsErrorDiagnostics(null);
return;
}
@ -557,6 +611,7 @@ export function useRuntimeProviderManagement(
let cancelled = false;
setModelsLoading(true);
setModelsError(null);
setModelsErrorDiagnostics(null);
void withUiTimeout(
api.runtimeProviderManagement.loadModels({
runtimeId: options.runtimeId,
@ -574,6 +629,7 @@ export function useRuntimeProviderManagement(
if (response.error) {
setModels([]);
setModelsError(response.error.message);
setModelsErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
const nextModels = response.models?.models ?? [];
@ -593,6 +649,7 @@ export function useRuntimeProviderManagement(
? modelsLoadError.message
: 'Failed to load provider models'
);
setModelsErrorDiagnostics(null);
}
})
.finally(() => {
@ -678,7 +735,9 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null);
setSetupForm(null);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupMetadata({});
setApiKeyValue('');
@ -704,6 +763,7 @@ export function useRuntimeProviderManagement(
const searchAllProviders = useCallback((query: string): void => {
setDirectoryQuery(query);
setDirectoryError(null);
setDirectoryErrorDiagnostics(null);
setDirectoryNextCursor(null);
}, []);
@ -716,9 +776,12 @@ export function useRuntimeProviderManagement(
setSetupMetadata({});
setSetupForm(null);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupFormLoading(true);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
const requestSeq = setupFormRequestSeq.current + 1;
@ -740,11 +803,13 @@ export function useRuntimeProviderManagement(
}
if (response.error) {
setSetupFormError(response.error.message);
setSetupFormErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
setSetupForm(response.setupForm ?? null);
if (!response.setupForm) {
setSetupFormError('Provider setup form response was empty');
setSetupFormErrorDiagnostics(null);
}
})
.catch((setupError) => {
@ -754,6 +819,7 @@ export function useRuntimeProviderManagement(
setSetupFormError(
setupError instanceof Error ? setupError.message : 'Failed to load provider setup form'
);
setSetupFormErrorDiagnostics(null);
})
.finally(() => {
if (requestIsCurrent()) {
@ -784,13 +850,17 @@ export function useRuntimeProviderManagement(
setSetupForm(null);
setSetupFormLoading(false);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setError(null);
setErrorDiagnostics(null);
}, []);
const updateApiKeyValue = useCallback((value: string): void => {
setApiKeyValue(value);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
}, []);
const setSetupMetadataValue = useCallback((key: string, value: string): void => {
@ -799,29 +869,35 @@ export function useRuntimeProviderManagement(
[key]: value,
}));
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
}, []);
const submitConnect = useCallback(
async (providerId: string): Promise<void> => {
if (!setupForm) {
setSetupSubmitError(setupFormError ?? 'Provider setup form is not loaded');
setSetupSubmitErrorDiagnostics(setupFormErrorDiagnostics ?? null);
return;
}
if (!setupForm.supported) {
setSetupSubmitError(
setupForm.disabledReason ?? 'Provider setup is not supported in the app'
);
setSetupSubmitErrorDiagnostics(null);
return;
}
const apiKey = apiKeyValue.trim();
if (setupForm.secret?.required && !apiKey) {
setSetupSubmitError(`${setupForm.secret.label} is required`);
setSetupSubmitErrorDiagnostics(null);
return;
}
setSavingProviderId(providerId);
setError(null);
setErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
try {
@ -841,6 +917,7 @@ export function useRuntimeProviderManagement(
}
if (response.error) {
setSetupSubmitError(response.error.message);
setSetupSubmitErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
if (response.provider) {
@ -852,7 +929,9 @@ export function useRuntimeProviderManagement(
setSetupMetadata({});
setSetupForm(null);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
try {
await options.onProviderChanged?.();
if (!isProjectContextCurrent(projectContext)) {
@ -869,6 +948,7 @@ export function useRuntimeProviderManagement(
setError(
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
);
setErrorDiagnostics(null);
}
} catch (connectError) {
if (!isProjectContextCurrent(projectContext)) {
@ -877,6 +957,7 @@ export function useRuntimeProviderManagement(
setSetupSubmitError(
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
);
setSetupSubmitErrorDiagnostics(null);
} finally {
if (isProjectContextCurrent(projectContext)) {
setSavingProviderId(null);
@ -892,6 +973,7 @@ export function useRuntimeProviderManagement(
refresh,
setupForm,
setupFormError,
setupFormErrorDiagnostics,
setupMetadata,
]
);
@ -900,6 +982,7 @@ export function useRuntimeProviderManagement(
async (providerId: string): Promise<void> => {
setSavingProviderId(providerId);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
try {
@ -916,6 +999,7 @@ export function useRuntimeProviderManagement(
}
if (response.error) {
setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
if (response.provider) {
@ -938,6 +1022,7 @@ export function useRuntimeProviderManagement(
setError(
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
);
setErrorDiagnostics(null);
}
if (!isProjectContextCurrent(projectContext)) {
return;
@ -950,6 +1035,7 @@ export function useRuntimeProviderManagement(
setError(
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
);
setErrorDiagnostics(null);
} finally {
if (isProjectContextCurrent(projectContext)) {
setSavingProviderId(null);
@ -965,6 +1051,7 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null);
openModelPickerState(providerId, mode);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
},
[openModelPickerState]
@ -979,6 +1066,7 @@ export function useRuntimeProviderManagement(
setSelectedModelId(modelId);
setSuccessMessage(null);
setError(null);
setErrorDiagnostics(null);
}, []);
const testModel = useCallback(
@ -994,6 +1082,7 @@ export function useRuntimeProviderManagement(
current.includes(modelId) ? current : [...current, modelId]
);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
try {
const response = await withUiTimeout(
@ -1007,6 +1096,10 @@ export function useRuntimeProviderManagement(
100_000
);
if (response.error) {
if (response.error.diagnostics && shouldRecordProbeResult()) {
setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics);
}
if (shouldRecordProbeResult()) {
const result = buildFailedModelTestResult(providerId, modelId, response.error.message);
setModelResults((current) => ({
@ -1064,6 +1157,7 @@ export function useRuntimeProviderManagement(
): Promise<void> => {
setSavingDefaultModelId(modelId);
setError(null);
setErrorDiagnostics(null);
setSuccessMessage(null);
const projectContext = getProjectContextSnapshot();
try {
@ -1084,6 +1178,7 @@ export function useRuntimeProviderManagement(
}
if (response.error) {
setError(response.error.message);
setErrorDiagnostics(response.error.diagnostics ?? null);
return;
}
const proofResult: RuntimeProviderModelTestResultDto = {
@ -1130,6 +1225,7 @@ export function useRuntimeProviderManagement(
setError(
defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default'
);
setErrorDiagnostics(null);
} finally {
if (isProjectContextCurrent(projectContext)) {
setSavingDefaultModelId(null);
@ -1146,7 +1242,9 @@ export function useRuntimeProviderManagement(
setActiveFormProviderId(null);
setSetupForm(null);
setSetupFormError(null);
setSetupFormErrorDiagnostics(null);
setSetupSubmitError(null);
setSetupSubmitErrorDiagnostics(null);
setSetupMetadata({});
setApiKeyValue('');
if (activeModelPickerProviderRef.current !== providerId) {
@ -1199,6 +1297,7 @@ export function useRuntimeProviderManagement(
directoryLoading,
directoryRefreshing,
directoryError,
directoryErrorDiagnostics,
directoryEntries,
directoryTotalCount,
directoryNextCursor,
@ -1209,7 +1308,9 @@ export function useRuntimeProviderManagement(
setupForm,
setupFormLoading,
setupFormError,
setupFormErrorDiagnostics,
setupSubmitError,
setupSubmitErrorDiagnostics,
setupMetadata,
apiKeyValue,
modelPickerProviderId,
@ -1218,6 +1319,7 @@ export function useRuntimeProviderManagement(
models,
modelsLoading,
modelsError,
modelsErrorDiagnostics,
selectedModelId,
testingModelIds,
savingDefaultModelId,
@ -1225,18 +1327,22 @@ export function useRuntimeProviderManagement(
loading,
savingProviderId,
error,
errorDiagnostics,
successMessage,
}),
[
activeFormProviderId,
apiKeyValue,
setupForm,
setupFormErrorDiagnostics,
setupFormError,
setupFormLoading,
setupSubmitErrorDiagnostics,
setupSubmitError,
setupMetadata,
directoryEntries,
directoryError,
directoryErrorDiagnostics,
directoryLoaded,
directoryLoading,
directoryNextCursor,
@ -1245,12 +1351,14 @@ export function useRuntimeProviderManagement(
directorySupported,
directoryTotalCount,
error,
errorDiagnostics,
loading,
modelPickerMode,
modelPickerProviderId,
modelQuery,
modelResults,
models,
modelsErrorDiagnostics,
modelsError,
modelsLoading,
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 { Button } from '@renderer/components/ui/button';
@ -20,7 +20,9 @@ import {
} from '@renderer/utils/openCodeModelRecommendations';
import {
AlertTriangle,
Check,
CheckCircle2,
ClipboardList,
KeyRound,
Loader2,
RefreshCcw,
@ -47,6 +49,7 @@ import type {
RuntimeProviderDefaultModelSourceDto,
RuntimeProviderDefaultScopeDto,
RuntimeProviderDirectoryEntryDto,
RuntimeProviderManagementErrorDiagnosticsDto,
RuntimeProviderModelDto,
RuntimeProviderModelTestResultDto,
RuntimeProviderSetupPromptDto,
@ -84,6 +87,12 @@ interface ProviderRowProps {
readonly actions: RuntimeProviderManagementActions;
}
interface RuntimeProviderErrorAlertProps {
readonly message: string;
readonly diagnostics?: RuntimeProviderManagementErrorDiagnosticsDto | null;
readonly testId: string;
}
type OpenCodeSettingsSection = 'models' | 'providers';
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 loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId;
const error = state.setupFormError;
const errorDiagnostics = state.setupFormErrorDiagnostics;
const submitError =
state.activeFormProviderId === provider.providerId ? state.setupSubmitError : null;
const submitErrorDiagnostics =
state.activeFormProviderId === provider.providerId ? state.setupSubmitErrorDiagnostics : null;
const canSubmit = setupFormCanSubmit(state, provider.providerId);
return (
@ -356,9 +368,11 @@ function ProviderSetupFormPanel({
) : null}
{!loading && error ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{error}
</div>
<RuntimeProviderErrorAlert
message={error}
diagnostics={errorDiagnostics}
testId="runtime-provider-setup-form-error"
/>
) : null}
{!loading && form ? (
@ -445,8 +459,12 @@ function ProviderSetupFormPanel({
) : null}
{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">
{submitError}
<div className="mt-3">
<RuntimeProviderErrorAlert
message={submitError}
diagnostics={submitErrorDiagnostics}
testId="runtime-provider-setup-submit-error"
/>
</div>
) : 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 {
return (
<div className="space-y-2" data-testid="runtime-provider-model-loading-skeleton">
@ -1096,8 +1336,8 @@ function ModelBadges({
}): JSX.Element | null {
const modelRecommendation = getOpenCodeTeamModelRecommendation(model.modelId);
const localRoute = model.routeKind === 'configured_local';
const builtinFreeRoute = model.routeKind === 'builtin_free';
const connectedRoute = model.routeKind === 'connected_provider';
const freeModel = isFreeRuntimeProviderModel(model);
const verified =
model.proofState === 'verified' ||
model.availability === 'available' ||
@ -1111,8 +1351,7 @@ function ModelBadges({
const unknown = model.accessKind === 'unknown_model' || model.accessKind === 'no_model';
if (
!model.free &&
!builtinFreeRoute &&
!freeModel &&
!model.default &&
!usedForNewTeams &&
!modelRecommendation &&
@ -1163,7 +1402,7 @@ function ModelBadges({
Used in team picker
</Badge>
) : null}
{model.free ? (
{freeModel ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200">free</Badge>
) : null}
{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>
</>
) : null}
{builtinFreeRoute && !model.free ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200">free</Badge>
) : null}
{connectedRoute ? (
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-100">
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 {
return model.accessKind === 'unknown_model' || model.accessKind === 'no_model';
}
@ -1245,7 +1494,7 @@ function getOpenCodeModelSearchText(model: RuntimeProviderModelDto): string {
model.proofState,
model.availability,
model.accessReason ?? '',
model.free ? 'free' : '',
isFreeRuntimeProviderModel(model) ? 'free' : '',
model.default ? 'default' : '',
model.requiresExecutionProof ? 'needs test needs probe' : '',
recommendation?.label ?? '',
@ -1688,10 +1937,15 @@ function ProviderModelList({
}): JSX.Element {
const pickerOpen = state.modelPickerProviderId === provider.providerId;
const [recommendedOnly, setRecommendedOnly] = useState(false);
const [freeOnly, setFreeOnly] = useState(false);
const hasRecommendedModels = useMemo(
() => state.models.some((model) => isOpenCodeTeamModelRecommended(model.modelId)),
[state.models]
);
const hasFreeModels = useMemo(
() => state.models.some((model) => isFreeRuntimeProviderModel(model)),
[state.models]
);
useEffect(() => {
if (!hasRecommendedModels) {
@ -1699,11 +1953,18 @@ function ProviderModelList({
}
}, [hasRecommendedModels]);
useEffect(() => {
if (!hasFreeModels) {
setFreeOnly(false);
}
}, [hasFreeModels]);
const visibleModels = useMemo(
() =>
state.models
.map((model, index) => ({ model, index }))
.filter(({ model }) => !recommendedOnly || isOpenCodeTeamModelRecommended(model.modelId))
.filter(({ model }) => !freeOnly || isFreeRuntimeProviderModel(model))
.sort((left, right) => {
const recommendationOrder = compareOpenCodeTeamModelRecommendations(
left.model.modelId,
@ -1712,8 +1973,15 @@ function ProviderModelList({
return recommendationOrder || left.index - right.index;
})
.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 (
<div className="mt-4 space-y-3 border-t border-white/10 pt-3">
@ -1753,12 +2021,35 @@ function ProviderModelList({
</Label>
</div>
) : 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>
{state.modelsError ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{state.modelsError}
</div>
<RuntimeProviderErrorAlert
message={state.modelsError}
diagnostics={state.modelsErrorDiagnostics}
testId="runtime-provider-models-error"
/>
) : null}
<div
@ -1768,9 +2059,7 @@ function ProviderModelList({
>
{!pickerOpen || state.modelsLoading ? <RuntimeProviderModelLoadingSkeleton /> : null}
{pickerOpen && !state.modelsLoading && visibleModels.length === 0 && !state.modelsError ? (
<div className="text-sm text-[var(--color-text-muted)]">
{recommendedOnly ? 'No recommended models found.' : 'No models found.'}
</div>
<div className="text-sm text-[var(--color-text-muted)]">{emptyModelListMessage}</div>
) : null}
{pickerOpen
? visibleModels.map((model) => (
@ -1843,17 +2132,11 @@ export function RuntimeProviderManagementPanelView({
<RuntimeSummary state={state} disabled={disabled} onRefresh={() => void actions.refresh()} />
{state.error ? (
<div
className="flex 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" />
<span>{state.error}</span>
</div>
<RuntimeProviderErrorAlert
message={state.error}
diagnostics={state.errorDiagnostics}
testId="runtime-provider-error"
/>
) : null}
{state.successMessage ? (
@ -1988,9 +2271,11 @@ export function RuntimeProviderManagementPanelView({
) : null}
{state.directoryError ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{state.directoryError}
</div>
<RuntimeProviderErrorAlert
message={state.directoryError}
diagnostics={state.directoryErrorDiagnostics}
testId="runtime-provider-directory-error"
/>
) : null}
<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';
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
import { killTrackedCliProcesses } from '@main/utils/childProcess';
import { getWindowsElevationStatus } from '@main/utils/windowsElevation';
import {
APP_GET_WINDOWS_ELEVATION_STATUS,
APP_STARTUP_GET_STATUS,
APP_STARTUP_PROGRESS,
CONTEXT_CHANGED,
@ -147,6 +149,7 @@ import { clearAutoResumeService } from './services/team/AutoResumeService';
import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer';
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient';
import { OpenCodeBridgeDiagnosticsStore } from './services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore';
import {
createOpenCodeBridgeCommandLeaseStore,
createOpenCodeBridgeCommandLedgerStore,
@ -391,6 +394,10 @@ async function createOpenCodeRuntimeAdapterRegistry(
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
const useHttpMcpBridge = isOpenCodeMcpHttpBridgeEnabled(bridgeEnv);
const explicitLocalMcpLaunchEnv = snapshotOpenCodeLocalMcpLaunchEnv(bridgeEnv);
delete bridgeEnv.ELECTRON_RUN_AS_NODE;
if (explicitLocalMcpLaunchEnv) {
copyOpenCodeLocalMcpLaunchEnv(explicitLocalMcpLaunchEnv, bridgeEnv);
}
delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
const applyMcpLaunchSpecEnv = async (
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_ENTRY = mcpEntry;
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) {
logger.warn(
@ -515,13 +525,16 @@ async function createOpenCodeRuntimeAdapterRegistry(
}
return nextEnv;
};
const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
const bridgeClient = new OpenCodeBridgeCommandClient({
binaryPath,
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
env: bridgeEnv,
envProvider: resolveBridgeCommandEnv,
diagnostics: new OpenCodeBridgeDiagnosticsStore({
directory: join(bridgeControlDir, 'diagnostics'),
}),
});
const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
const clientIdentity = createOpenCodeBridgeClientIdentity({
appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.3.0',
gitSha: process.env.VITE_GIT_SHA ?? process.env.GIT_SHA ?? null,
@ -546,6 +559,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
});
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
stateChangingCommands,
appVersion: clientIdentity.appVersion,
});
openCodeLifecycleBridge = readinessBridge;
return new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(readinessBridge)]);
@ -963,6 +977,7 @@ function registerAppStartupHandlers(): void {
appStartupHandlersRegistered = true;
registerRendererLogHandlers(ipcMain);
ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus);
ipcMain.handle(APP_GET_WINDOWS_ELEVATION_STATUS, () => getWindowsElevationStatus());
}
function cloneStartupSteps(): AppStartupStep[] {

View file

@ -97,7 +97,6 @@ import {
import { wrapAgentBlock } from '@shared/constants/agentBlocks';
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import {
extractFlagsFromHelp,
extractUserFlags,
@ -111,7 +110,6 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import {
buildStandaloneSlashCommandMeta,
parseStandaloneSlashCommand,
@ -133,7 +131,6 @@ import {
import {
getAutoResumeService,
initializeAutoResumeService,
planRateLimitAutoResume,
} from '../services/team/AutoResumeService';
import {
cloneLaunchIoGovernorPayload,
@ -156,6 +153,7 @@ import {
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
import { teamMessageNotificationScanner } from './teams/teamMessageNotificationScanner';
import {
validateFromField,
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>(
promise: Promise<T>,
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 teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
@ -1145,17 +963,24 @@ async function handleGetData(
if (live.length === 0) {
if (durableMessages.length > 0) {
checkRateLimitMessages(
durableMessages,
tn,
displayName,
teamMessageNotificationScanner.checkRateLimitMessages(durableMessages, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
isAlive,
currentLeadSessionId
);
checkApiErrorMessages(durableMessages, tn, displayName, projectPath);
teamIsAlive: isAlive,
currentLeadSessionId,
});
teamMessageNotificationScanner.checkApiErrorMessages(durableMessages, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
} else {
scanTeamMessageNotifications(live, tn, displayName, projectPath);
teamMessageNotificationScanner.scan(live, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
}
return { success: true, data: { ...data, isAlive } };
}
@ -1177,8 +1002,18 @@ async function handleGetData(
}
}
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
checkApiErrorMessages(merged, tn, displayName, projectPath);
teamMessageNotificationScanner.checkRateLimitMessages(merged, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
teamIsAlive: isAlive,
currentLeadSessionId,
});
teamMessageNotificationScanner.checkApiErrorMessages(merged, {
teamName: tn,
teamDisplayName: displayName,
projectPath,
});
return { success: true, data: { ...data, isAlive } };
}
@ -2844,12 +2679,11 @@ async function handleGetMessagesPage(
.catch(() => ({ displayName: teamName }));
void notificationContextPromise
.then((notificationContext) => {
scanTeamMessageNotifications(
messagesPage.messages,
teamMessageNotificationScanner.scan(messagesPage.messages, {
teamName,
notificationContext.displayName,
notificationContext.projectPath
);
teamDisplayName: notificationContext.displayName,
projectPath: notificationContext.projectPath,
});
})
.catch((error: unknown) => {
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
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({
kind: 'teams',
rootPath: this.teamsPath,
@ -479,6 +483,8 @@ export class FileWatcher extends EventEmitter {
// Guard: stop() may have been called while awaiting pathExists
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({
kind: 'tasks',
rootPath: this.tasksPath,
@ -647,6 +653,9 @@ export class FileWatcher extends EventEmitter {
error: unknown,
watcher?: CloseableWatcher
): 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)) {
return false;
}
@ -721,6 +730,8 @@ export class FileWatcher extends EventEmitter {
};
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));
timer.unref();
@ -799,6 +810,8 @@ export class FileWatcher extends EventEmitter {
const snapshot = new Map<string, string>();
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) {
if (!teamEntry.isDirectory()) {
continue;
@ -825,6 +838,8 @@ export class FileWatcher extends EventEmitter {
const snapshot = new Map<string, string>();
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) {
if (!teamEntry.isDirectory()) {
continue;
@ -1351,6 +1366,9 @@ export class FileWatcher extends EventEmitter {
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') {
const event: TeamChangeEvent = { type: 'process', teamName, detail: relative };
this.emit('team-change', event);
@ -1414,6 +1432,8 @@ export class FileWatcher extends EventEmitter {
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
if (
relative === '.lock' ||

View file

@ -23,8 +23,8 @@ import type {
const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 25_000;
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 15_000;
const PROVIDER_STATUS_TIMEOUT_MS = 90_000;
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 30_000;
const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
@ -396,11 +396,21 @@ function createRuntimeStatusErrorProviderStatus(
error: unknown
): CliProviderStatus {
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 {
...createDefaultProviderStatus(providerId),
verificationState: 'error',
statusMessage: 'Provider status unavailable',
detailMessage: message,
detailMessage,
};
}
@ -985,8 +995,11 @@ export class ClaudeMultimodelBridgeService {
if (options.summary) {
args.push('--summary');
}
const timeout =
options.timeoutMs ??
(options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS);
const { stdout } = await execCli(binaryPath, args, {
timeout: options.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS,
timeout,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
env,
});

View file

@ -8,6 +8,8 @@ const logger = createLogger('Runtime:AgentTeamsMcpLaunchEnv');
const MCP_COMMAND_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND';
const MCP_ENTRY_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY';
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>;
@ -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(
env: AgentTeamsMcpLaunchEnv,
resolveLaunchSpec: () => Promise<McpLaunchSpec> = resolveAgentTeamsMcpLaunchSpec
): Promise<void> {
if (hasAgentTeamsMcpLocalLaunchEnv(env)) {
ensureLegacyMcpChildEnvJson(env);
return;
}
@ -36,6 +51,7 @@ export async function ensureAgentTeamsMcpLocalLaunchEnv(
env[MCP_COMMAND_ENV] = command;
env[MCP_ENTRY_ENV] = entry;
env[MCP_ARGS_JSON_ENV] = JSON.stringify(launchSpec.args);
env[MCP_ENV_JSON_ENV] = JSON.stringify(launchSpec.env ?? {});
} catch (error) {
logger.warn(
`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';
type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined;
const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
export interface ProviderAwareCliEnvOptions {
binaryPath?: string | null;
@ -29,6 +30,10 @@ export interface ProviderAwareCliEnvResult {
providerArgs: string[];
}
function removeGlobalElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): void {
delete env[ELECTRON_RUN_AS_NODE_ENV];
}
export async function buildProviderAwareCliEnv(
options: ProviderAwareCliEnvOptions = {}
): Promise<ProviderAwareCliEnvResult> {
@ -79,6 +84,7 @@ export async function buildProviderAwareCliEnv(
options.providerBackendId,
...storedApiKeyAccessArgs
);
removeGlobalElectronRunAsNodeEnv(env);
return {
env,
connectionIssues: {},
@ -92,22 +98,25 @@ export async function buildProviderAwareCliEnv(
options.providerBackendId,
...storedApiKeyAccessArgs
);
removeGlobalElectronRunAsNodeEnv(env);
return {
env,
providerArgs: await providerConnectionService.getConfiguredConnectionLaunchArgs(
const providerArgs = await providerConnectionService.getConfiguredConnectionLaunchArgs(
env,
resolvedProviderId,
options.providerBackendId,
options.binaryPath
),
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(
);
const connectionIssues = await providerConnectionService.getConfiguredConnectionIssues(
env,
[resolvedProviderId],
resolvedProviderId === 'codex' || resolvedProviderId === 'gemini'
? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined }
: undefined
),
);
return {
env,
providerArgs,
connectionIssues,
};
}
@ -116,6 +125,7 @@ export async function buildProviderAwareCliEnv(
env,
...storedApiKeyAccessArgs
);
removeGlobalElectronRunAsNodeEnv(env);
return {
env,
connectionIssues: {},
@ -124,9 +134,11 @@ export async function buildProviderAwareCliEnv(
}
await providerConnectionService.applyAllConfiguredConnectionEnv(env, ...storedApiKeyAccessArgs);
removeGlobalElectronRunAsNodeEnv(env);
const connectionIssues = await providerConnectionService.getConfiguredConnectionIssues(env);
return {
env,
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env),
connectionIssues,
providerArgs: [],
};
}

View file

@ -405,7 +405,12 @@ function buildStatePath(): 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(
@ -1095,6 +1100,7 @@ export class AgentTeamsMcpHttpServer {
};
const childEnv = applyAgentTeamsIdentityEnv({
...process.env,
...launchSpec.env,
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
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 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 TASK_MAP_YIELD_EVERY = 250;
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
@ -3221,7 +3221,8 @@ export class TeamDataService {
const MAX_SCAN_BYTES = 8 * 1024 * 1024;
const INITIAL_SCAN_BYTES = 256 * 1024;
const textsReversed: InboxMessage[] = [];
const rawLinesReversed: string[] = [];
const seenRawLines = new Set<string>();
const seenMessageIds = new Set<string>();
const handle = await fs.promises.open(jsonlPath, 'r');
try {
@ -3229,7 +3230,7 @@ export class TeamDataService {
const fileSize = stat.size;
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 buffer = Buffer.alloc(scanBytes);
await handle.read(buffer, 0, scanBytes, start);
@ -3241,37 +3242,32 @@ export class TeamDataService {
for (let i = lines.length - 1; i >= fromIndex; i--) {
const trimmed = lines[i]?.trim();
if (!trimmed) continue;
let msg: Record<string, unknown>;
try {
msg = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
if (seenRawLines.has(trimmed)) continue;
seenRawLines.add(trimmed);
rawLinesReversed.push(trimmed);
}
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 (scanBytes === fileSize) break;
scanBytes = Math.min(fileSize, scanBytes * 2);
}
} finally {
await handle.close();
}
if (textParts.length === 0) continue;
const combined = stripAgentBlocks(textParts.join('\n')).trim();
if (combined.length < MIN_TEXT_LENGTH) continue;
const rawLines = rawLinesReversed.reverse();
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(i + 200, lines.length);
for (let j = i + 1; j < lookaheadLimit; j++) {
const tLine = lines[j]?.trim();
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 {
@ -3279,7 +3275,7 @@ export class TeamDataService {
} catch {
continue;
}
if (tMsg.type !== 'assistant') continue;
if (tMsg.type !== 'assistant') break;
const tMessage = (tMsg.message ?? tMsg) as Record<string, unknown>;
const tContent = tMessage.content;
if (!Array.isArray(tContent)) continue;
@ -3295,13 +3291,25 @@ export class TeamDataService {
}
}
}
const toolCalls = toolCallsList.length > 0 ? toolCallsList : undefined;
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
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
? `lead-thought-${entryUuid}`
? streamGroup
? `lead-thought-stream-${entryUuid}`
: `lead-thought-${entryUuid}`
: assistantMessageId
? `lead-thought-msg-${assistantMessageId}`
: null;
@ -3313,10 +3321,11 @@ export class TeamDataService {
const messageId =
stableMessageId ?? `lead-session-${leadSessionId}-${timestamp}-${textPrefix}`;
if (seenMessageIds.has(messageId)) continue;
if (seenMessageIds.has(messageId)) return;
seenMessageIds.add(messageId);
textsReversed.push({
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
texts.push({
from: leadName,
text: combined,
timestamp,
@ -3327,19 +3336,81 @@ export class TeamDataService {
toolSummary,
toolCalls,
});
if (textsReversed.length >= maxTexts) break;
};
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 (textsReversed.length >= maxTexts) break;
if (scanBytes === fileSize) break;
scanBytes = Math.min(fileSize, scanBytes * 2);
}
} finally {
await handle.close();
if (msg.type !== 'assistant') {
flushSyntheticBuffer();
continue;
}
textsReversed.reverse();
return textsReversed.length > maxTexts ? textsReversed.slice(-maxTexts) : textsReversed;
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(

View file

@ -21,6 +21,7 @@ import type { TeamMemberMcpPolicy, TeamMemberMcpScope } from '@shared/types';
export interface McpLaunchSpec {
command: string;
args: string[];
env?: Record<string, string>;
}
export interface McpLaunchSpecResolveProgress {
@ -40,10 +41,12 @@ interface WriteMcpConfigOptions {
const MCP_SERVER_NAME = 'agent-teams';
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
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 MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
const ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
/**
* Stale configs older than this are removed on startup (best-effort).
* 7 days is intentionally long: respawnAfterAuthFailure() reuses saved
@ -58,6 +61,7 @@ const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'pro
function isPackagedApp(): boolean {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { app } = require('electron') as typeof import('electron');
return app.isPackaged;
} catch {
@ -88,6 +92,26 @@ function getWorkspaceRoot(): string {
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 {
return path.join(getWorkspaceRoot(), 'mcp-server');
}
@ -178,9 +202,11 @@ async function hasValidServerCopy(dir: string): Promise<boolean> {
}
let _resolvedNodePath: string | undefined;
let _packagedElectronNodeRuntimeProbe: { ok: true } | { ok: false; error: unknown } | undefined;
export function clearResolvedNodePathForTests(): void {
_resolvedNodePath = undefined;
_packagedElectronNodeRuntimeProbe = undefined;
}
function emitProgress(
@ -301,6 +327,37 @@ async function probeNodeRuntimePath(
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(
options?: McpLaunchSpecResolveOptions
): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> {
@ -450,6 +507,27 @@ export async function resolveAgentTeamsMcpLaunchSpec(
const packagedEntry = await resolvePackagedServerEntry(options);
checked.push(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 {
command: await resolveNodePath(options),
args: [packagedEntry],
@ -520,6 +598,7 @@ export class TeamMcpConfigBuilder {
args: launchSpec.args,
enabled: true,
env: {
...launchSpec.env,
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
...(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 { execCli } from '@main/utils/childProcess';
import { randomUUID } from 'crypto';
import { createHash, randomUUID } from 'crypto';
import { promises as fs } from 'fs';
import * as path from 'path';
@ -38,6 +38,14 @@ export interface OpenCodeBridgeProcessRunner {
run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult>;
}
interface OpenCodeBridgeOutputReadResult {
content: string;
outputSource: 'stdout' | 'file' | 'none';
stdoutBytes: number;
outputFileBytes: number | null;
outputReadError: string | null;
}
export interface OpenCodeBridgeDiagnosticsSink {
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 EMPTY_STDOUT_READINESS_MAX_ATTEMPTS = 2;
const EMPTY_STDOUT_READINESS_RETRY_DELAY_MS = 250;
const SAFE_BRIDGE_INPUT_FILE_REQUEST_ID = /^[A-Za-z0-9._-]{1,120}$/;
export function resolveOpenCodeBridgeProcessCwd(
binaryPath: string,
@ -185,7 +194,16 @@ export class OpenCodeBridgeCommandClient {
stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES,
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) {
return this.contractFailure(
@ -196,6 +214,7 @@ export class OpenCodeBridgeCommandClient {
{
stderr: redactBridgeDiagnosticText(processResult.stderr),
attempts: attempt,
...processDetails,
}
);
}
@ -207,14 +226,14 @@ export class OpenCodeBridgeCommandClient {
'OpenCode bridge command failed',
true,
{
exitCode: processResult.exitCode,
stderr: redactBridgeDiagnosticText(processResult.stderr),
attempts: attempt,
...processDetails,
}
);
}
const parsed = parseSingleBridgeJsonResult<TData>(stdout);
const parsed = parseSingleBridgeJsonResult<TData>(bridgeOutput.content);
if (!parsed.ok) {
if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) {
await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS);
@ -222,9 +241,10 @@ export class OpenCodeBridgeCommandClient {
}
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)),
attempts: attempt,
...processDetails,
});
}
@ -232,6 +252,7 @@ export class OpenCodeBridgeCommandClient {
if (!validation.ok) {
return this.contractFailure(envelope, 'contract_violation', validation.reason, false, {
attempts: attempt,
...processDetails,
});
}
@ -253,14 +274,56 @@ export class OpenCodeBridgeCommandClient {
}
}
private async readBridgeOutput(stdout: string, outputPath: string): Promise<string> {
if (stdout.trim().length > 0) {
return stdout;
}
private async readBridgeOutput(
stdout: string,
outputPath: string
): Promise<OpenCodeBridgeOutputReadResult> {
const stdoutBytes = byteLength(stdout);
try {
return await fs.readFile(outputPath, 'utf8');
} catch {
return stdout;
const output = await fs.readFile(outputPath, 'utf8');
const outputFileBytes = byteLength(output);
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>
): Promise<string> {
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`, {
encoding: 'utf8',
mode: 0o600,
@ -291,6 +354,13 @@ export class OpenCodeBridgeCommandClient {
details: Record<string, unknown>
): Promise<OpenCodeBridgeFailure> {
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 = {
id: this.diagnosticIdFactory(),
type:
@ -301,11 +371,11 @@ export class OpenCodeBridgeCommandClient {
runId: extractRunId(envelope.body) ?? undefined,
severity: retryable ? 'warning' : 'error',
message,
data: details,
data: diagnosticDetails,
createdAt: completedAt,
};
await this.diagnostics?.append(diagnostic);
await this.diagnostics?.append(diagnostic).catch(() => undefined);
return {
ok: false,
@ -318,7 +388,7 @@ export class OpenCodeBridgeCommandClient {
kind,
message,
retryable,
details,
details: diagnosticDetails,
},
diagnostics: [diagnostic],
};
@ -356,3 +426,38 @@ function bufferToString(value: string | Buffer | undefined): string {
}
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;
projectPath: string;
selectedModel: string;
skipPermissions?: boolean;
members: OpenCodeTeamLaunchMemberCommandSpec[];
leadPrompt: string;
expectedCapabilitySnapshotId: string | null;
@ -71,6 +72,15 @@ export interface OpenCodeLaunchTeamCommandBody {
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 {
sessionId: string;
launchState: OpenCodeTeamMemberLaunchBridgeState;
@ -78,6 +88,7 @@ export interface OpenCodeTeamMemberLaunchCommandData {
bootstrapMode?: OpenCodeBootstrapMode;
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
pendingPermissionRequestIds?: string[];
pendingPermissions?: OpenCodeRuntimePermissionCommandData[];
diagnostics?: string[];
model: string;
runtimePid?: number;
@ -132,6 +143,30 @@ export interface OpenCodeStopTeamCommandData {
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 {
reason: 'startup' | 'shutdown' | 'manual' | string;
mode?: 'stale' | 'force';
@ -590,6 +625,7 @@ export function assertBridgeResultCanMutateState<TData>(
command: OpenCodeBridgeCommandName;
runId: string | null;
capabilitySnapshotId: string | null;
allowCapabilitySnapshotRecovery?: boolean;
}
): asserts result is OpenCodeBridgeSuccess<TData> {
if (!result.ok) {
@ -612,12 +648,28 @@ export function assertBridgeResultCanMutateState<TData>(
if (
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');
}
}
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: {
handshake: OpenCodeBridgeHandshake;
expectedClient: OpenCodeBridgePeerIdentity;
@ -744,6 +796,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
manifest: RuntimeStoreManifestEvidence;
idempotencyKey: string;
enforceManifestHighWatermark?: boolean;
allowCapabilitySnapshotRecovery?: boolean;
}): asserts input is {
result: OpenCodeBridgeSuccess<unknown>;
requestId: string;
@ -758,6 +811,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
command: input.command,
runId: input.runId,
capabilitySnapshotId: input.capabilitySnapshotId,
allowCapabilitySnapshotRecovery: input.allowCapabilitySnapshotRecovery,
});
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_ARGS_JSON',
] 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>;
@ -17,6 +19,17 @@ export function hasOpenCodeLocalMcpLaunchEnv(env: OpenCodeMcpBridgeEnv): boolean
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: {
httpBridgeEnabled: boolean;
mcpUrl: string | undefined;
@ -36,6 +49,20 @@ export function copyOpenCodeLocalMcpLaunchEnv(
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(
@ -54,4 +81,10 @@ export function clearOpenCodeLocalMcpLaunchEnv(env: OpenCodeMcpBridgeEnv): void
for (const key of LOCAL_MCP_LAUNCH_ENV_KEYS) {
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,
stableHash,
} from './OpenCodeBridgeCommandContract';
import { buildOpenCodeBridgeSupportDiagnostic } from './OpenCodeBridgeSupportDiagnostics';
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
import type {
@ -11,6 +12,7 @@ import type {
OpenCodeTeamLaunchReadinessState,
} from '../readiness/OpenCodeTeamLaunchReadiness';
import type {
OpenCodeAnswerPermissionCommandBody,
OpenCodeBackfillTaskLedgerCommandBody,
OpenCodeBackfillTaskLedgerCommandData,
OpenCodeBridgeCommandName,
@ -24,6 +26,8 @@ import type {
OpenCodeCommandStatusCommandData,
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData,
OpenCodeListRuntimePermissionsCommandBody,
OpenCodeListRuntimePermissionsCommandData,
OpenCodeObserveMessageDeliveryCommandBody,
OpenCodeObserveMessageDeliveryCommandData,
OpenCodeReconcileTeamCommandBody,
@ -62,6 +66,7 @@ export interface OpenCodeReadinessBridgeOptions {
observeTimeoutMs?: number;
stopTimeoutMs?: number;
cleanupTimeoutMs?: number;
appVersion?: string;
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
}
@ -80,6 +85,7 @@ const DEFAULT_SEND_TIMEOUT_MS = 45_000;
const DEFAULT_OBSERVE_TIMEOUT_MS = 20_000;
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
const DEFAULT_PERMISSION_TIMEOUT_MS = 30_000;
const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000;
const DEFAULT_COMMAND_STATUS_TIMEOUT_MS = 5_000;
const OPEN_CODE_COMPLETED_COMMAND_RECOVERY_MESSAGE =
@ -118,6 +124,12 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
}
this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath);
const supportDiagnostic = buildOpenCodeBridgeSupportDiagnostic({
result,
projectPath: input.projectPath,
selectedModel: input.selectedModel,
appVersion: this.options.appVersion ?? null,
});
return blockedReadiness({
state: mapBridgeFailureToReadinessState(result.error.kind),
modelId: input.selectedModel,
@ -126,6 +138,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
...result.diagnostics.map(formatDiagnosticEvent),
],
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(
input: OpenCodeCleanupHostsCommandBody
): Promise<OpenCodeCleanupHostsCommandData> {
@ -565,7 +612,11 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
type OpenCodeStateChangingTeamCommandName = Extract<
OpenCodeBridgeCommandName,
'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' | 'opencode.sendMessage'
| 'opencode.launchTeam'
| 'opencode.reconcileTeam'
| 'opencode.stopTeam'
| 'opencode.sendMessage'
| 'opencode.answerPermission'
>;
function blockedLaunchData(
@ -600,6 +651,7 @@ function blockedReadiness(input: {
modelId: string | null;
diagnostics: string[];
missing: string[];
supportDiagnostics?: OpenCodeTeamLaunchReadiness['supportDiagnostics'];
}): OpenCodeTeamLaunchReadiness {
return {
state: input.state,
@ -617,6 +669,9 @@ function blockedReadiness(input: {
supportLevel: null,
missing: dedupe(input.missing),
diagnostics: dedupe(input.diagnostics),
...(input.supportDiagnostics?.length
? { supportDiagnostics: [...input.supportDiagnostics] }
: {}),
evidence: {
capabilitiesReady: false,
mcpToolProofRoute: null,

View file

@ -245,6 +245,10 @@ export class OpenCodeStateChangingBridgeCommandService {
manifest,
idempotencyKey,
enforceManifestHighWatermark,
allowCapabilitySnapshotRecovery: isOpenCodeLaunchCapabilitySnapshotRecoveryAttempt(
input.command,
input.body
),
});
} catch (error) {
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);
}
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(
command: OpenCodeBridgeCommandName,
body: unknown

View file

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

View file

@ -1,12 +1,14 @@
import { randomUUID } from 'crypto';
import type {
OpenCodeAnswerPermissionCommandBody,
OpenCodeBridgeRuntimeSnapshot,
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData,
OpenCodeObserveMessageDeliveryCommandBody,
OpenCodeObserveMessageDeliveryCommandData,
OpenCodeReconcileTeamCommandBody,
OpenCodeRuntimePermissionCommandData,
OpenCodeSendMessageCommandBody,
OpenCodeSendMessageCommandData,
OpenCodeStopTeamCommandBody,
@ -20,6 +22,8 @@ import type {
TeamRuntimeLaunchResult,
TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberStopEvidence,
TeamRuntimePendingPermission,
TeamRuntimePermissionAnswerInput,
TeamRuntimePrepareResult,
TeamRuntimeReconcileInput,
TeamRuntimeReconcileResult,
@ -52,6 +56,9 @@ export interface OpenCodeTeamRuntimeBridgePort {
observeOpenCodeTeamMessageDelivery?(
input: OpenCodeObserveMessageDeliveryCommandBody
): Promise<OpenCodeObserveMessageDeliveryCommandData>;
answerOpenCodeRuntimePermission?(
input: OpenCodeAnswerPermissionCommandBody
): Promise<OpenCodeLaunchTeamCommandData>;
}
export interface OpenCodeTeamRuntimeMessageInput {
@ -199,6 +206,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
retryable: isRetryableReadinessState(readiness.state),
diagnostics: mergeDiagnostics(readiness.diagnostics, readiness.missing),
warnings: [],
...(readiness.supportDiagnostics?.length
? { supportDiagnostics: [...readiness.supportDiagnostics] }
: {}),
};
}
@ -208,6 +218,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
modelId: readiness.modelId,
diagnostics: readiness.diagnostics,
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 launchWarnings: string[] = [];
if (!skipReadinessPreflight) {
@ -290,6 +305,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
teamName: input.teamName,
projectPath: input.cwd,
selectedModel: model,
skipPermissions: input.skipPermissions,
members: input.expectedMembers.map((member) => ({
name: member.name,
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> {
if (this.bridge.stopOpenCodeTeam) {
const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
@ -701,6 +753,7 @@ function mapOpenCodeLaunchDataToRuntimeResult(
bridgeMember?.model,
bridgeMember?.runtimePid,
bridgeMember?.pendingPermissionRequestIds,
bridgeMember?.pendingPermissions,
bridgeMember != null,
memberDiagnostics,
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(
memberName: string,
launchState: OpenCodeTeamMemberLaunchBridgeState,
@ -805,6 +885,7 @@ function mapBridgeMemberToRuntimeEvidence(
model: string | undefined,
runtimePid: number | undefined,
pendingPermissionRequestIds: string[] | undefined,
pendingPermissions: OpenCodeRuntimePermissionCommandData[] | undefined,
runtimeMaterialized: boolean,
diagnostics: string[],
runId: string,
@ -863,6 +944,7 @@ function mapBridgeMemberToRuntimeEvidence(
: pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized
? 'warning'
: undefined;
const normalizedPendingApprovals = normalizeOpenCodeRuntimePendingPermissions(pendingPermissions);
return {
memberName,
providerId: 'opencode',
@ -887,6 +969,8 @@ function mapBridgeMemberToRuntimeEvidence(
pendingPermissionRequestIds && pendingPermissionRequestIds.length > 0
? [...new Set(pendingPermissionRequestIds)]
: undefined,
pendingApprovals: normalizedPendingApprovals,
pendingPermissions: normalizedPendingApprovals,
sessionId,
...(appManagedCandidatePresent
? { bootstrapEvidenceSource: 'app_managed_bootstrap' as const }

View file

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

View file

@ -6,11 +6,14 @@ export type {
export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter';
export type {
TeamLaunchRuntimeAdapter,
TeamRuntimeApprovalProviderId,
TeamRuntimeLaunchInput,
TeamRuntimeLaunchResult,
TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberSpec,
TeamRuntimeMemberStopEvidence,
TeamRuntimePendingApproval,
TeamRuntimePendingPermission,
TeamRuntimePrepareFailure,
TeamRuntimePrepareResult,
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.
* 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 launcherDir = path.dirname(binaryPath);
return (
resolveGeneratedBunLauncher(content, launcherDir) ?? resolveNpmNodeShim(content, launcherDir)
resolveGeneratedBunLauncher(content, launcherDir) ??
resolveNpmNodeShim(content, launcherDir) ??
resolveNpmNativeShim(content, launcherDir)
);
} catch {
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 */
export const APP_STARTUP_PROGRESS = 'appStartup:progress';
/** Renderer -> main Windows elevation status request */
export const APP_GET_WINDOWS_ELEVATION_STATUS = 'app:getWindowsElevationStatus';
// =============================================================================
// Telemetry Channels
// =============================================================================

View file

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

View file

@ -98,6 +98,7 @@ import type {
UpdaterAPI,
UpdateSchedulePatch,
WaterfallData,
WindowsElevationStatus,
WslClaudeRootCandidate,
} from '@shared/types';
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');
getWindowsElevationStatus = async (): Promise<WindowsElevationStatus> => ({
platform: 'browser',
isWindows: false,
isAdministrator: null,
checkFailed: false,
error: null,
});
getCodexAccountSnapshot = (): Promise<CodexAccountSnapshotDto> =>
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 { TmuxStatusBanner } from './TmuxStatusBanner';
import { WebPreviewBanner } from './WebPreviewBanner';
import { WindowsAdministratorBanner } from './WindowsAdministratorBanner';
interface CommandSearchProps {
value: string;
@ -114,6 +115,7 @@ export const DashboardView = (): React.JSX.Element => {
<div className="relative mx-auto max-w-5xl px-8 py-12">
<WebPreviewBanner />
<WindowsAdministratorBanner />
<DashboardUpdateBanner />
<CliStatusBanner />
<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 (
<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
sessions. We are actively building broader plugin support for all agents, including
both universal plugins and agent-specific plugins.
{capability.reason ? ` ${capability.reason}` : ''}
Plugin support is currently guaranteed for Anthropic (Claude) sessions only.
We&apos;re working to support plugins across all agents.
</div>
);
})()}

View file

@ -1082,6 +1082,7 @@ export const CreateTeamDialog = ({
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
backendSummary: plan.backendSummary,
details: plan.cachedSnapshot.details,
supportDiagnostics: undefined,
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
}
@ -1150,6 +1151,7 @@ export const CreateTeamDialog = ({
status,
backendSummary: plan.backendSummary,
details,
supportDiagnostics: undefined,
}
);
commitChecks(nextChecks);
@ -1181,6 +1183,7 @@ export const CreateTeamDialog = ({
status: prepResult.status,
backendSummary: plan.backendSummary,
details: prepResult.details,
supportDiagnostics: prepResult.supportDiagnostics,
});
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage);
@ -1194,6 +1197,7 @@ export const CreateTeamDialog = ({
status: 'failed',
backendSummary: plan.backendSummary,
details: [failureMessage],
supportDiagnostics: undefined,
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
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',
backendSummary: plan.backendSummary,
details: plan.cachedSnapshot.details,
supportDiagnostics: undefined,
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
}
@ -1722,6 +1723,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
status,
backendSummary: plan.backendSummary,
details,
supportDiagnostics: undefined,
});
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage);
@ -1752,6 +1754,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
status: prepResult.status,
backendSummary: plan.backendSummary,
details: prepResult.details,
supportDiagnostics: prepResult.supportDiagnostics,
});
commitChecks(nextChecks);
applyPrepareOutcome(nextChecks, loadingMessage);
@ -1765,6 +1768,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
status: 'failed',
backendSummary: plan.backendSummary,
details: [failureMessage],
supportDiagnostics: undefined,
});
prepareWarningsByProviderIdRef.current.delete(plan.providerId);
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 { 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 ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed';
@ -14,6 +22,7 @@ export interface ProvisioningProviderCheck {
status: ProvisioningProviderCheckStatus;
backendSummary?: string | null;
details: string[];
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
}
export function getProvisioningProviderLabel(providerId: TeamProviderId): string {
@ -151,6 +160,8 @@ export function getProvisioningProviderProgressMessage(
type ProvisioningDetailSummary =
| 'CLI binary missing'
| 'OpenCode runtime missing'
| 'OpenCode Windows access blocked'
| 'OpenCode runtime check returned no output'
| 'OpenCode app MCP unreachable'
| 'Working directory missing'
| 'CLI binary could not be started'
@ -174,6 +185,16 @@ function isSelectedModelDetail(lower: string): boolean {
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 {
return (
lower.includes(' - checking...') ||
@ -219,10 +240,17 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
function summarizeDetail(
detail: string,
status: ProvisioningProviderCheckStatus
status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): ProvisioningDetailSummary | null {
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')) {
return 'CLI binary missing';
}
@ -449,13 +477,15 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
}
const summarizedDetails = publicDetails
.map((detail) => summarizeDetail(detail, check.status))
.map((detail) => summarizeDetail(detail, check.status, check.providerId))
.filter((detail): detail is ProvisioningDetailSummary => Boolean(detail));
const summary =
check.status === 'failed'
? (summarizedDetails.find(
(detail) =>
detail === 'OpenCode Windows access blocked' ||
detail === 'OpenCode runtime check returned no output' ||
detail === 'OpenCode app MCP unreachable' ||
detail === 'OpenCode runtime missing' ||
detail === 'Selected model unavailable' ||
@ -472,9 +502,10 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
function getDetailTone(
detail: string,
status: ProvisioningProviderCheckStatus
status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): 'success' | 'failure' | 'checking' | 'neutral' {
const summary = summarizeDetail(detail, status);
const summary = summarizeDetail(detail, status, providerId);
if (
summary === 'Selected model verified' ||
summary === 'Selected model available' ||
@ -493,6 +524,8 @@ function getDetailTone(
summary === 'Selected model check failed' ||
summary === 'CLI binary missing' ||
summary === 'OpenCode runtime missing' ||
summary === 'OpenCode Windows access blocked' ||
summary === 'OpenCode runtime check returned no output' ||
summary === 'OpenCode app MCP unreachable' ||
summary === 'Working directory missing' ||
summary === 'CLI binary could not be started' ||
@ -510,8 +543,12 @@ function getDetailTone(
return 'neutral';
}
function getDetailColorClass(detail: string, status: ProvisioningProviderCheckStatus): string {
switch (getDetailTone(detail, status)) {
function getDetailColorClass(
detail: string,
status: ProvisioningProviderCheckStatus,
providerId?: TeamProviderId
): string {
switch (getDetailTone(detail, status, providerId)) {
case 'success':
return 'text-emerald-400';
case 'failure':
@ -551,14 +588,14 @@ export function getPrimaryProvisioningFailureDetail(
const publicDetails = getPublicProvisioningDetails(check.details);
const preferredFailure = publicDetails.find(
(detail) => getDetailTone(detail, check.status) === 'failure'
(detail) => getDetailTone(detail, check.status, check.providerId) === 'failure'
);
if (preferredFailure) {
return preferredFailure;
}
const nonSuccessDetail = publicDetails.find(
(detail) => getDetailTone(detail, check.status) !== 'success'
(detail) => getDetailTone(detail, check.status, check.providerId) !== 'success'
);
if (nonSuccessDetail) {
return nonSuccessDetail;
@ -715,6 +752,16 @@ function getProvisioningProviderSettingsActionLabel(
: 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 = ({
checks,
className = '',
@ -726,10 +773,29 @@ export const ProvisioningProviderStatusList = ({
suppressDetailsMatching?: string | null;
onOpenProviderSettings?: (providerId: TeamProviderId) => void;
}): React.JSX.Element | null => {
const [copiedDiagnosticsKey, setCopiedDiagnosticsKey] = React.useState<string | null>(null);
if (checks.length === 0) {
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 (
<div className={`space-y-1 pl-5 ${className}`.trim()}>
{checks.map((check) => {
@ -740,6 +806,12 @@ export const ProvisioningProviderStatusList = ({
const settingsActionLabel = onOpenProviderSettings
? getProvisioningProviderSettingsActionLabel(check)
: null;
const supportDiagnosticsPayload = getSupportDiagnosticsPayload(check);
const supportDiagnosticsKey =
supportDiagnosticsPayload && check.supportDiagnostics?.[0]
? `${check.providerId}:${check.supportDiagnostics[0].id}`
: check.providerId;
const copiedDiagnostics = copiedDiagnosticsKey === supportDiagnosticsKey;
return (
<div key={check.providerId}>
@ -758,7 +830,11 @@ export const ProvisioningProviderStatusList = ({
{visibleDetails.map((detail, index) => (
<p
key={`${check.providerId}:${index}:${detail}`}
className={`text-[10px] ${getDetailColorClass(detail, check.status)}`}
className={`text-[10px] ${getDetailColorClass(
detail,
check.status,
check.providerId
)}`}
>
{detail}
</p>
@ -781,6 +857,24 @@ export const ProvisioningProviderStatusList = ({
</button>
</div>
) : 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>
);
})}
@ -792,6 +886,34 @@ export function getProvisioningFailureHint(
message: string | null | undefined,
checks: ProvisioningProviderCheck[]
): 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)]
.join('\n')
.toLowerCase();

View file

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

View file

@ -104,6 +104,7 @@ interface OpenCodeModelOptionMetadata {
pricingInfo: OpenCodeModelPricingInfo | null;
searchText: string;
isRecommended: boolean;
isFree: boolean;
}
interface OpenCodeVirtualHeadingRow {
@ -220,6 +221,24 @@ function buildOpenCodeModelSearchText({
.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 {
const safeWidth = Number.isFinite(width) ? Math.max(0, width) : 0;
if (safeWidth <= 0) {
@ -721,6 +740,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}) => {
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const [recommendedOnly, setRecommendedOnly] = useState(false);
const [freeOnly, setFreeOnly] = useState(false);
const [modelQuery, setModelQuery] = useState('');
const [openCodeSourceFilterOpen, setOpenCodeSourceFilterOpen] = useState(false);
const [openCodeSourceQuery, setOpenCodeSourceQuery] = useState('');
@ -958,6 +978,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
pricingInfo,
}),
isRecommended: isRecommendedTeamModelRecommendation(recommendation),
isFree: isFreeOpenCodeModelOption({ option, routeMetadata, pricingInfo }),
};
});
}, [effectiveProviderId, modelOptions, openCodeCatalogModelById]);
@ -969,6 +990,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
() => openCodeModelMetadata.some((metadata) => metadata.isRecommended),
[openCodeModelMetadata]
);
const hasFreeOpenCodeModels = useMemo(
() => openCodeModelMetadata.some((metadata) => metadata.isFree),
[openCodeModelMetadata]
);
useEffect(() => {
if (previousSelectedProviderIdRef.current === selectedProviderId) {
@ -984,6 +1009,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}
}, [effectiveProviderId, hasRecommendedOpenCodeModels, recommendedOnly]);
useEffect(() => {
if (freeOnly && (effectiveProviderId !== 'opencode' || !hasFreeOpenCodeModels)) {
setFreeOnly(false);
}
}, [effectiveProviderId, freeOnly, hasFreeOpenCodeModels]);
useEffect(() => {
if (previousEffectiveProviderIdRef.current === effectiveProviderId) {
return;
@ -1023,6 +1054,9 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
if (recommendedOnly && !metadata.isRecommended) {
continue;
}
if (freeOnly && !metadata.isFree) {
continue;
}
const sourceInfo = metadata.sourceInfo;
if (!sourceInfo) {
@ -1040,7 +1074,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return Array.from(sourceOptions.values()).sort((left, right) =>
left.label.localeCompare(right.label, undefined, { sensitivity: 'base' })
);
}, [effectiveProviderId, openCodeModelMetadata, recommendedOnly]);
}, [effectiveProviderId, freeOnly, openCodeModelMetadata, recommendedOnly]);
useEffect(() => {
if (selectedOpenCodeSourceIds.size === 0) {
@ -1105,6 +1139,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const concreteOptions = openCodeModelMetadata
.filter((metadata) => metadata.option.value.trim().length > 0)
.filter((metadata) => !recommendedOnly || metadata.isRecommended)
.filter((metadata) => !freeOnly || metadata.isFree)
.filter((metadata) => {
if (selectedOpenCodeSourceIds.size === 0) {
return true;
@ -1123,7 +1158,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return recommendationOrder || left.index - right.index;
});
if (recommendedOnly) {
if (recommendedOnly || freeOnly) {
return concreteOptions;
}
@ -1135,6 +1170,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
];
}, [
effectiveProviderId,
freeOnly,
modelQuery,
openCodeModelMetadata,
recommendedOnly,
@ -1220,6 +1256,15 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
effectiveProviderId === 'opencode' &&
!shouldShowOpenCodeCatalogLoading &&
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
? null
: getProviderDisabledReason(effectiveProviderId);
@ -1689,7 +1734,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
) : null}
{!shouldShowOpenCodeCatalogLoading &&
((effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1) ||
hasRecommendedOpenCodeModels) ? (
hasRecommendedOpenCodeModels ||
hasFreeOpenCodeModels) ? (
<div className="mb-2 flex flex-wrap items-center gap-2">
{effectiveProviderId === 'opencode' && openCodeSourceOptions.length > 1 ? (
<Popover
@ -1785,6 +1831,22 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</Label>
</div>
) : 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>
) : null}
{effectiveProviderId === 'opencode' ? (
@ -1869,11 +1931,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
)}
{visibleModelOptions.length === 0 && !shouldShowOpenCodeCatalogLoading ? (
<div className="rounded-md border border-white/10 px-3 py-2 text-xs text-[var(--color-text-muted)]">
{trimmedModelQuery
? '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.'}
{emptyModelListMessage}
</div>
) : null}
</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 {
isOpenCodeWindowsAccessDeniedDiagnostic,
normalizeOpenCodeWindowsAccessDeniedDiagnostic,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
import type {
@ -6,6 +10,7 @@ import type {
TeamProvisioningModelCheckRequest,
TeamProvisioningModelVerificationMode,
TeamProvisioningPrepareResult,
TeamProvisioningSupportDiagnostic,
} from '@shared/types';
export type ProviderPrepareCheckStatus = 'ready' | 'notes' | 'failed';
@ -45,6 +50,7 @@ export interface ProviderPrepareDiagnosticsResult {
details: string[];
warnings: string[];
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
}
type TeamProvisioningPrepareIssue = NonNullable<TeamProvisioningPrepareResult['issues']>[number];
@ -93,6 +99,45 @@ function uniquePrepareLines(lines: (string | null | undefined)[]): string[] {
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 {
if (isDefaultProviderModelSelection(modelId)) {
return 'Default';
@ -287,8 +332,12 @@ function looksLikeOpenCodeRuntimeFailureReason(reason: string | null | undefined
}
return (
isOpenCodeBridgeNoOutputDiagnostic(reason) ||
isOpenCodeWindowsAccessDeniedDiagnostic(reason) ||
lower.includes('opencode /experimental/tool') ||
lower.includes('/experimental/tool') ||
lower.includes('opencode_bridge_contract_violation') ||
lower.includes('bridge stdout was empty') ||
lower.includes('mcp_unavailable') ||
lower.includes('unable to connect') ||
lower.includes('runtime store') ||
@ -339,7 +388,9 @@ function isAdvisoryOpenCodeDeepVerificationIssue(
lower.includes('/experimental/tool') ||
lower.includes('runtime store') ||
lower.includes('opencode cli') ||
lower.includes('opencode runtime binary');
lower.includes('opencode runtime binary') ||
isOpenCodeBridgeNoOutputDiagnostic(lower) ||
isOpenCodeWindowsAccessDeniedDiagnostic(lower);
if (hasHardRuntimeMarker) {
return false;
}
@ -476,27 +527,48 @@ function buildModelVerificationDeferredLine(
: `${label} - verification deferred`;
}
function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string[] {
return uniquePrepareLines([...(result.details ?? []), ...(result.warnings ?? [])]);
function createRuntimeDetailLines(
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(
(result.warnings ?? [])
.map((warning) => normalizeRuntimeFailureDetailLine(warning))
.map((warning) => normalizeRuntimeFailureDetailLine(warning, undefined, providerId))
.filter(Boolean)
);
}
function normalizeRuntimeFailureDetailLine(
detail: string | null | undefined,
code?: string | null
code?: string | null,
providerId?: TeamProviderId
): string | null {
const trimmed = detail?.trim();
if (!trimmed) {
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)) {
return 'OpenCode runtime binary is not installed or not reachable by launch preflight.';
}
@ -518,13 +590,33 @@ function normalizeRuntimeFailureDetailLine(
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(
runtimeDetailLines: readonly string[],
message: string | null | undefined
message: string | null | undefined,
providerId: TeamProviderId
): string[] {
return uniquePrepareLines(
[...runtimeDetailLines, message]
.map((detail) => normalizeRuntimeFailureDetailLine(detail))
.map((detail) => normalizeRuntimeFailureDetailLine(detail, undefined, providerId))
.filter(Boolean)
);
}
@ -957,6 +1049,7 @@ export async function runProviderPrepareDiagnostics({
);
const hasExplicitModelChecks = (selectedModelChecks?.length ?? 0) > 0;
const orderedModelIds = Array.from(new Set(normalizedModelChecks.map((check) => check.model)));
const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
if (orderedModelIds.length === 0) {
const runtimeResult = await prepareProvisioning(
cwd,
@ -965,24 +1058,35 @@ export async function runProviderPrepareDiagnostics({
undefined,
limitContext
);
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
const runtimeWarnings = createRuntimeWarningLines(runtimeResult);
mergeSupportDiagnostics(supportDiagnostics, runtimeResult.supportDiagnostics);
const runtimeDetailLines = createRuntimeDetailLines(runtimeResult, providerId);
const runtimeWarnings = createRuntimeWarningLines(runtimeResult, providerId);
if (!runtimeResult.ready) {
return {
return withSupportDiagnostics(
{
status: 'failed',
details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message),
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
runtimeResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
};
},
supportDiagnostics
);
}
return {
return withSupportDiagnostics(
{
status: runtimeWarnings.length > 0 ? 'notes' : 'ready',
details: runtimeDetailLines,
warnings: runtimeWarnings,
modelResultsById: {},
};
},
supportDiagnostics
);
}
const reusableModelResultsById = cachedModelResultsById ?? {};
@ -1047,16 +1151,24 @@ export async function runProviderPrepareDiagnostics({
undefined,
limitContext
);
runtimeDetailLines = createRuntimeDetailLines(runtimeResult);
runtimeWarnings = createRuntimeWarningLines(runtimeResult);
mergeSupportDiagnostics(supportDiagnostics, runtimeResult.supportDiagnostics);
runtimeDetailLines = createRuntimeDetailLines(runtimeResult, providerId);
runtimeWarnings = createRuntimeWarningLines(runtimeResult, providerId);
if (!runtimeResult.ready) {
return {
return withSupportDiagnostics(
{
status: 'failed',
details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message),
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
runtimeResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
};
},
supportDiagnostics
);
}
} else {
const recordTerminalModelResult = (
@ -1090,10 +1202,11 @@ export async function runProviderPrepareDiagnostics({
? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)]
: [])
);
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
mergeSupportDiagnostics(supportDiagnostics, compatibilityResult.supportDiagnostics);
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter(
runtimeWarnings = createRuntimeWarningLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
@ -1116,17 +1229,21 @@ export async function runProviderPrepareDiagnostics({
const structuredProviderScopedFailure =
structuredProviderScopedIssue?.message.trim() ?? null;
if (structuredProviderScopedFailure || providerScopedFailure) {
return {
return withSupportDiagnostics(
{
status: 'failed',
details: [
normalizeRuntimeFailureDetailLine(
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
structuredProviderScopedIssue?.code
structuredProviderScopedIssue?.code,
providerId
) ?? 'OpenCode failed',
],
warnings: [],
modelResultsById: {},
};
},
supportDiagnostics
);
}
if (
shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
@ -1141,15 +1258,19 @@ export async function runProviderPrepareDiagnostics({
(uncachedModelIds.length > 1 ||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)))
) {
return {
return withSupportDiagnostics(
{
status: 'failed',
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
compatibilityResult.message
compatibilityResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
};
},
supportDiagnostics
);
}
if (!hasModelScopedEntries && uncachedModelIds.length === 1) {
runtimeDetailLines = [];
@ -1204,7 +1325,8 @@ export async function runProviderPrepareDiagnostics({
)
);
return {
return withSupportDiagnostics(
{
status: hasFailure
? 'failed'
: hasNotes || dedupedWarnings.length > 0
@ -1216,7 +1338,9 @@ export async function runProviderPrepareDiagnostics({
],
warnings: dedupedWarnings,
modelResultsById: selectedModelResultsById,
};
},
supportDiagnostics
);
}
try {
@ -1231,10 +1355,11 @@ export async function runProviderPrepareDiagnostics({
? [selectModelChecksForIds(normalizedModelChecks, compatibilityPassedModelIds)]
: [])
);
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult).filter(
mergeSupportDiagnostics(supportDiagnostics, batchedModelResult.supportDiagnostics);
runtimeDetailLines = createRuntimeDetailLines(batchedModelResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
);
runtimeWarnings = createRuntimeWarningLines(batchedModelResult).filter(
runtimeWarnings = createRuntimeWarningLines(batchedModelResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(compatibilityPassedModelIds, entry)
);
@ -1278,17 +1403,21 @@ export async function runProviderPrepareDiagnostics({
}
handledAdvisoryDeepFailure = true;
} else {
return {
return withSupportDiagnostics(
{
status: 'failed',
details: [
normalizeRuntimeFailureDetailLine(
failureReason,
structuredProviderScopedIssue?.code
structuredProviderScopedIssue?.code,
providerId
) ?? failureReason,
],
warnings: [],
modelResultsById: {},
};
},
supportDiagnostics
);
}
}
if (
@ -1323,15 +1452,19 @@ export async function runProviderPrepareDiagnostics({
(compatibilityPassedModelIds.length > 1 ||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))))
) {
return {
return withSupportDiagnostics(
{
status: 'failed',
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
batchedModelResult.message
batchedModelResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
};
},
supportDiagnostics
);
}
if (
!handledAdvisoryDeepFailure &&
@ -1385,10 +1518,10 @@ export async function runProviderPrepareDiagnostics({
? [selectModelChecksForIds(normalizedModelChecks, uncachedModelIds)]
: [])
);
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult).filter(
runtimeDetailLines = createRuntimeDetailLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
runtimeWarnings = createRuntimeWarningLines(compatibilityResult).filter(
runtimeWarnings = createRuntimeWarningLines(compatibilityResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
@ -1410,7 +1543,8 @@ export async function runProviderPrepareDiagnostics({
status: 'failed',
details: createRuntimeFailureDetailLines(
runtimeDetailLines,
compatibilityResult.message
compatibilityResult.message,
providerId
),
warnings: runtimeWarnings,
modelResultsById: {},
@ -1445,10 +1579,10 @@ export async function runProviderPrepareDiagnostics({
limitContext,
'deep'
);
runtimeDetailLines = createRuntimeDetailLines(deepResult).filter(
runtimeDetailLines = createRuntimeDetailLines(deepResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
runtimeWarnings = createRuntimeWarningLines(deepResult).filter(
runtimeWarnings = createRuntimeWarningLines(deepResult, providerId).filter(
(entry) => !isModelScopedEntryForAnyModel(uncachedModelIds, entry)
);
if (
@ -1502,7 +1636,8 @@ export async function runProviderPrepareDiagnostics({
)
);
return {
return withSupportDiagnostics(
{
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
details: [
...filteredRuntime.runtimeDetailLines,
@ -1510,5 +1645,7 @@ export async function runProviderPrepareDiagnostics({
],
warnings: dedupedWarnings,
modelResultsById: selectedModelResultsById,
};
},
supportDiagnostics
);
}

View file

@ -1043,7 +1043,7 @@ export const MemberCard = memo(function MemberCard({
<MemberRuntimeTelemetryStrip runtimeEntry={runtimeEntry} scale={runtimeTelemetryScale} />
) : null}
<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="rounded-full border-2 p-px"
@ -1166,6 +1166,7 @@ export const MemberCard = memo(function MemberCard({
<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}
</>
@ -1201,22 +1202,8 @@ export const MemberCard = memo(function MemberCard({
) : null}
</div>
) : null}
{launchFailureReason ? (
<div
data-testid="member-launch-failure-reason"
className="mt-1 min-w-0 whitespace-pre-wrap break-words text-[10px] font-medium leading-snug text-red-300/90"
title={rawLaunchFailureReason}
>
<span>
{renderLinkifiedText(launchFailureReason, {
linkClassName: 'underline underline-offset-2 hover:text-red-200',
stopPropagation: true,
getLinkLabel: getLaunchFailureLinkLabel,
})}
</span>
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2.5 justify-self-end">
{showLaunchBadge ? (
<span
className="flex shrink-0 items-center gap-1"
@ -1244,7 +1231,9 @@ export const MemberCard = memo(function MemberCard({
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
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}
@ -1283,6 +1272,7 @@ export const MemberCard = memo(function MemberCard({
<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 ? (
@ -1313,7 +1303,9 @@ export const MemberCard = memo(function MemberCard({
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
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}
@ -1355,7 +1347,9 @@ export const MemberCard = memo(function MemberCard({
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
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}
@ -1406,7 +1400,9 @@ export const MemberCard = memo(function MemberCard({
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
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}
@ -1428,6 +1424,7 @@ export const MemberCard = memo(function MemberCard({
<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>
@ -1533,6 +1530,22 @@ export const MemberCard = memo(function MemberCard({
</Tooltip>
) : null}
</div>
{launchFailureReason ? (
<div
data-testid="member-launch-failure-reason"
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}
>
<span>
{renderLinkifiedText(launchFailureReason, {
linkClassName: 'underline underline-offset-2 hover:text-red-200',
stopPropagation: true,
getLinkLabel: getLaunchFailureLinkLabel,
})}
</span>
</div>
) : null}
</div>
</div>
</div>
);
@ -1549,7 +1562,7 @@ export const MemberCard = memo(function MemberCard({
>
<TooltipTrigger asChild>{cardContent}</TooltipTrigger>
<TooltipContent
side="top"
side="left"
align="start"
sideOffset={8}
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
import {
formatMemberLaunchDiagnosticsPayload,
type MemberLaunchDiagnosticsPayload,
@ -13,6 +14,7 @@ interface MemberLaunchDiagnosticsButtonProps {
label?: string;
className?: string;
size?: 'icon' | 'sm';
attention?: boolean;
}
export const MemberLaunchDiagnosticsButton = ({
@ -20,6 +22,7 @@ export const MemberLaunchDiagnosticsButton = ({
label,
className,
size = label ? 'sm' : 'icon',
attention = false,
}: MemberLaunchDiagnosticsButtonProps): React.JSX.Element => {
const [copied, setCopied] = useState(false);
@ -45,7 +48,7 @@ export const MemberLaunchDiagnosticsButton = ({
type="button"
variant="ghost"
size={size}
className={className}
className={cn(className, attention && !copied && 'member-launch-diagnostics-pulse')}
title={tooltip}
aria-label={tooltip}
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 */
.member-waiting-shimmer {
position: relative;
@ -1653,6 +1691,8 @@ a[href],
@media (prefers-reduced-motion: reduce) {
.kanban-comment-badge-pulse,
.member-launch-diagnostics-pulse,
.member-launch-diagnostics-pulse::after,
.message-composer-orbit-path,
.message-composer-orbit-glow {
animation: none;

View file

@ -8,97 +8,6 @@
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<style>
/* 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';
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 {
const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp);
if (diff !== 0) return diff;
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.
*
@ -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>;
}
export interface WindowsElevationStatus {
platform: string;
isWindows: boolean;
isAdministrator: boolean | null;
checkFailed: boolean;
error: string | null;
}
// =============================================================================
// Main Electron API
// =============================================================================
@ -817,6 +825,7 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
startup?: AppStartupAPI;
telemetry: TelemetryAPI;
getAppVersion: () => Promise<string>;
getWindowsElevationStatus: () => Promise<WindowsElevationStatus>;
getProjects: () => Promise<Project[]>;
getSessions: (projectId: string) => Promise<Session[]>;
getSessionsPaginated: (

View file

@ -1500,12 +1500,24 @@ export interface TeamProvisioningPrepareIssue {
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 {
ready: boolean;
message: string;
details?: string[];
warnings?: string[];
issues?: TeamProvisioningPrepareIssue[];
supportDiagnostics?: TeamProvisioningSupportDiagnostic[];
}
export interface TeamProvisioningProgress {
@ -1771,6 +1783,8 @@ export interface ToolApprovalRequest {
/** Run ID — prevents stale approvals after stop→launch race. */
runId: 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'). */
source: string;
/** Tool name: 'Bash', 'Edit', 'Write', 'Read', etc. */
@ -1783,6 +1797,14 @@ export interface ToolApprovalRequest {
teamColor?: string;
/** Team display name (from config or create request). */
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).
* FACT: Populated by Claude Code runtime, contains instructions to add permission rules.
*/
@ -1837,7 +1859,7 @@ export interface ToolApprovalAutoResolved {
requestId: string;
runId: 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. */

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