merge: dev into main
2
.github/badges/version.svg
vendored
|
|
@ -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 |
85
.github/workflows/release.yml
vendored
|
|
@ -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}"
|
||||
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 "${VERSIONED_NAME}" \
|
||||
--pattern "${name}" \
|
||||
--dir "$TMP_DIR" \
|
||||
--clobber
|
||||
cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${ALIAS_NAME}"
|
||||
gh release upload "${TAG}" "${TMP_DIR}/${ALIAS_NAME}" --repo "$REPO" --clobber
|
||||
done
|
||||
}
|
||||
|
||||
upload_aliases "stable" STABLE_ALIASES
|
||||
upload_aliases "legacy stable" LEGACY_STABLE_ALIASES
|
||||
upload_aliases "legacy updater" LEGACY_UPDATER_ALIASES
|
||||
|
||||
- name: Publish canonical updater metadata
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
After Width: | Height: | Size: 719 KiB |
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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') },
|
||||
|
|
|
|||
|
|
@ -252,6 +252,9 @@ onUnmounted(() => {
|
|||
|
||||
<CyberHeroFeatureStrip
|
||||
class="cyber-hero__feature-strip"
|
||||
:active-message="activeHeroMessage"
|
||||
:phase="heroMessagePhase"
|
||||
:reduced-motion="heroReducedMotion"
|
||||
/>
|
||||
</v-container>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 448 KiB |
|
Before Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 684 KiB |
|
Before Width: | Height: | Size: 314 KiB |
|
Before Width: | Height: | Size: 588 KiB |
|
Before Width: | Height: | Size: 664 KiB |
|
Before Width: | Height: | Size: 704 KiB |
|
Before Width: | Height: | Size: 674 KiB |
|
Before Width: | Height: | Size: 597 KiB |
|
Before Width: | Height: | Size: 666 KiB |
|
Before Width: | Height: | Size: 770 KiB |
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
240
src/main/ipc/teams/teamMessageNotificationScanner.ts
Normal 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();
|
||||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: ${
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
const providerArgs = await providerConnectionService.getConfiguredConnectionLaunchArgs(
|
||||
env,
|
||||
resolvedProviderId,
|
||||
options.providerBackendId,
|
||||
options.binaryPath
|
||||
);
|
||||
const connectionIssues = await providerConnectionService.getConfiguredConnectionIssues(
|
||||
env,
|
||||
[resolvedProviderId],
|
||||
resolvedProviderId === 'codex' || resolvedProviderId === 'gemini'
|
||||
? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined }
|
||||
: undefined
|
||||
);
|
||||
return {
|
||||
env,
|
||||
providerArgs: await providerConnectionService.getConfiguredConnectionLaunchArgs(
|
||||
env,
|
||||
resolvedProviderId,
|
||||
options.providerBackendId,
|
||||
options.binaryPath
|
||||
),
|
||||
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(
|
||||
env,
|
||||
[resolvedProviderId],
|
||||
resolvedProviderId === 'codex' || resolvedProviderId === 'gemini'
|
||||
? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined }
|
||||
: undefined
|
||||
),
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,96 +3242,11 @@ 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 (msg.type !== 'assistant') continue;
|
||||
|
||||
const message = (msg.message ?? msg) as Record<string, unknown>;
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
|
||||
const timestamp =
|
||||
typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const block of content as Record<string, unknown>[]) {
|
||||
if (block.type !== 'text' || typeof block.text !== 'string') continue;
|
||||
textParts.push(block.text);
|
||||
}
|
||||
if (textParts.length === 0) continue;
|
||||
|
||||
const combined = stripAgentBlocks(textParts.join('\n')).trim();
|
||||
if (combined.length < MIN_TEXT_LENGTH) continue;
|
||||
|
||||
const toolCallsList: ToolCallMeta[] = [];
|
||||
const lookaheadLimit = Math.min(i + 200, lines.length);
|
||||
for (let j = i + 1; j < lookaheadLimit; j++) {
|
||||
const tLine = lines[j]?.trim();
|
||||
if (!tLine) continue;
|
||||
let tMsg: Record<string, unknown>;
|
||||
try {
|
||||
tMsg = JSON.parse(tLine) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (tMsg.type !== 'assistant') continue;
|
||||
const tMessage = (tMsg.message ?? tMsg) as Record<string, unknown>;
|
||||
const tContent = tMessage.content;
|
||||
if (!Array.isArray(tContent)) continue;
|
||||
const tBlocks = tContent as Record<string, unknown>[];
|
||||
if (tBlocks.some((b) => b.type === 'text')) break;
|
||||
for (const b of tBlocks) {
|
||||
if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
|
||||
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||
toolCallsList.push({
|
||||
name: b.name,
|
||||
preview: extractToolPreview(b.name, input),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const toolCalls = toolCallsList.length > 0 ? toolCallsList : undefined;
|
||||
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
|
||||
|
||||
const entryUuid = typeof msg.uuid === 'string' ? msg.uuid.trim() : '';
|
||||
const assistantMessageId = typeof message.id === 'string' ? message.id.trim() : '';
|
||||
const stableMessageId = entryUuid
|
||||
? `lead-thought-${entryUuid}`
|
||||
: assistantMessageId
|
||||
? `lead-thought-msg-${assistantMessageId}`
|
||||
: null;
|
||||
|
||||
const textPrefix = combined
|
||||
.slice(0, 50)
|
||||
.replace(/[^\p{L}\p{N}]/gu, '')
|
||||
.slice(0, 20);
|
||||
|
||||
const messageId =
|
||||
stableMessageId ?? `lead-session-${leadSessionId}-${timestamp}-${textPrefix}`;
|
||||
if (seenMessageIds.has(messageId)) continue;
|
||||
seenMessageIds.add(messageId);
|
||||
|
||||
textsReversed.push({
|
||||
from: leadName,
|
||||
text: combined,
|
||||
timestamp,
|
||||
read: true,
|
||||
source: 'lead_session',
|
||||
leadSessionId,
|
||||
messageId,
|
||||
toolSummary,
|
||||
toolCalls,
|
||||
});
|
||||
if (textsReversed.length >= maxTexts) break;
|
||||
if (seenRawLines.has(trimmed)) continue;
|
||||
seenRawLines.add(trimmed);
|
||||
rawLinesReversed.push(trimmed);
|
||||
}
|
||||
|
||||
if (textsReversed.length >= maxTexts) break;
|
||||
if (scanBytes === fileSize) break;
|
||||
scanBytes = Math.min(fileSize, scanBytes * 2);
|
||||
}
|
||||
|
|
@ -3338,8 +3254,163 @@ export class TeamDataService {
|
|||
await handle.close();
|
||||
}
|
||||
|
||||
textsReversed.reverse();
|
||||
return textsReversed.length > maxTexts ? textsReversed.slice(-maxTexts) : textsReversed;
|
||||
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(index + 200, rawLines.length);
|
||||
for (let j = index + 1; j < lookaheadLimit; j++) {
|
||||
const tLine = rawLines[j]?.trim();
|
||||
if (!tLine) continue;
|
||||
let tMsg: Record<string, unknown>;
|
||||
try {
|
||||
tMsg = JSON.parse(tLine) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (tMsg.type !== 'assistant') break;
|
||||
const tMessage = (tMsg.message ?? tMsg) as Record<string, unknown>;
|
||||
const tContent = tMessage.content;
|
||||
if (!Array.isArray(tContent)) continue;
|
||||
const tBlocks = tContent as Record<string, unknown>[];
|
||||
if (tBlocks.some((b) => b.type === 'text')) break;
|
||||
for (const b of tBlocks) {
|
||||
if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
|
||||
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||
toolCallsList.push({
|
||||
name: b.name,
|
||||
preview: extractToolPreview(b.name, input),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return toolCallsList.length > 0 ? toolCallsList : undefined;
|
||||
};
|
||||
|
||||
const pushLeadText = (
|
||||
msg: Record<string, unknown>,
|
||||
message: Record<string, unknown>,
|
||||
combined: string,
|
||||
timestamp: string,
|
||||
toolCalls?: ToolCallMeta[],
|
||||
streamGroup = false
|
||||
): void => {
|
||||
if (combined.length < MIN_TEXT_LENGTH) return;
|
||||
|
||||
const entryUuid = typeof msg.uuid === 'string' ? msg.uuid.trim() : '';
|
||||
const assistantMessageId = typeof message.id === 'string' ? message.id.trim() : '';
|
||||
const stableMessageId = entryUuid
|
||||
? streamGroup
|
||||
? `lead-thought-stream-${entryUuid}`
|
||||
: `lead-thought-${entryUuid}`
|
||||
: assistantMessageId
|
||||
? `lead-thought-msg-${assistantMessageId}`
|
||||
: null;
|
||||
|
||||
const textPrefix = combined
|
||||
.slice(0, 50)
|
||||
.replace(/[^\p{L}\p{N}]/gu, '')
|
||||
.slice(0, 20);
|
||||
|
||||
const messageId =
|
||||
stableMessageId ?? `lead-session-${leadSessionId}-${timestamp}-${textPrefix}`;
|
||||
if (seenMessageIds.has(messageId)) return;
|
||||
seenMessageIds.add(messageId);
|
||||
|
||||
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
|
||||
texts.push({
|
||||
from: leadName,
|
||||
text: combined,
|
||||
timestamp,
|
||||
read: true,
|
||||
source: 'lead_session',
|
||||
leadSessionId,
|
||||
messageId,
|
||||
toolSummary,
|
||||
toolCalls,
|
||||
});
|
||||
};
|
||||
|
||||
const flushSyntheticBuffer = (): void => {
|
||||
if (!syntheticBuffer) return;
|
||||
const combined = stripAgentBlocks(syntheticBuffer.parts.join('')).trim();
|
||||
pushLeadText(
|
||||
syntheticBuffer.firstMsg,
|
||||
syntheticBuffer.firstMessage,
|
||||
combined,
|
||||
syntheticBuffer.timestamp,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
syntheticBuffer = null;
|
||||
};
|
||||
|
||||
for (let i = 0; i < rawLines.length; i++) {
|
||||
const trimmed = rawLines[i]?.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.type !== 'assistant') {
|
||||
flushSyntheticBuffer();
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = (msg.message ?? msg) as Record<string, unknown>;
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) {
|
||||
flushSyntheticBuffer();
|
||||
continue;
|
||||
}
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const block of content as Record<string, unknown>[]) {
|
||||
if (block.type !== 'text' || typeof block.text !== 'string') continue;
|
||||
textParts.push(block.text);
|
||||
}
|
||||
|
||||
if (textParts.length === 0) {
|
||||
if ((content as Record<string, unknown>[]).some((block) => block.type === 'tool_use')) {
|
||||
flushSyntheticBuffer();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp =
|
||||
typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
|
||||
const isSyntheticChunk = message.model === '<synthetic>' && message.type === 'message';
|
||||
if (isSyntheticChunk) {
|
||||
if (!syntheticBuffer) {
|
||||
syntheticBuffer = {
|
||||
firstMsg: msg,
|
||||
firstMessage: message,
|
||||
timestamp,
|
||||
parts: [],
|
||||
};
|
||||
}
|
||||
syntheticBuffer.parts.push(textParts.join(''));
|
||||
continue;
|
||||
}
|
||||
|
||||
flushSyntheticBuffer();
|
||||
const combined = stripAgentBlocks(textParts.join('\n')).trim();
|
||||
pushLeadText(msg, message, combined, timestamp, collectToolCallsAfterIndex(i));
|
||||
}
|
||||
|
||||
flushSyntheticBuffer();
|
||||
return texts.length > maxTexts ? texts.slice(-maxTexts) : texts;
|
||||
}
|
||||
|
||||
private async extractLeadSessionTextsFromJsonl(
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ export type {
|
|||
export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter';
|
||||
export type {
|
||||
TeamLaunchRuntimeAdapter,
|
||||
TeamRuntimeApprovalProviderId,
|
||||
TeamRuntimeLaunchInput,
|
||||
TeamRuntimeLaunchResult,
|
||||
TeamRuntimeMemberLaunchEvidence,
|
||||
TeamRuntimeMemberSpec,
|
||||
TeamRuntimeMemberStopEvidence,
|
||||
TeamRuntimePendingApproval,
|
||||
TeamRuntimePendingPermission,
|
||||
TeamRuntimePrepareFailure,
|
||||
TeamRuntimePrepareResult,
|
||||
TeamRuntimePrepareSuccess,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
223
src/main/utils/windowsElevation.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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're working to support plugins across all agents.
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'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'll approve or deny each tool call in real-time.</p>
|
||||
<p>Manual mode: you'll approve or deny each tool call in real time.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
runtimeResult.message,
|
||||
providerId
|
||||
),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status: runtimeWarnings.length > 0 ? 'notes' : 'ready',
|
||||
details: runtimeDetailLines,
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
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 {
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(runtimeDetailLines, runtimeResult.message),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
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 {
|
||||
status: 'failed',
|
||||
details: [
|
||||
normalizeRuntimeFailureDetailLine(
|
||||
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
|
||||
structuredProviderScopedIssue?.code
|
||||
) ?? 'OpenCode failed',
|
||||
],
|
||||
warnings: [],
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
details: [
|
||||
normalizeRuntimeFailureDetailLine(
|
||||
structuredProviderScopedFailure ?? providerScopedFailure ?? 'OpenCode failed',
|
||||
structuredProviderScopedIssue?.code,
|
||||
providerId
|
||||
) ?? 'OpenCode failed',
|
||||
],
|
||||
warnings: [],
|
||||
modelResultsById: {},
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
if (
|
||||
shouldSurfaceProviderRuntimeFailureInsteadOfModelFailure({
|
||||
|
|
@ -1141,15 +1258,19 @@ export async function runProviderPrepareDiagnostics({
|
|||
(uncachedModelIds.length > 1 ||
|
||||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason)))
|
||||
) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
compatibilityResult.message
|
||||
),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
compatibilityResult.message,
|
||||
providerId
|
||||
),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
if (!hasModelScopedEntries && uncachedModelIds.length === 1) {
|
||||
runtimeDetailLines = [];
|
||||
|
|
@ -1204,19 +1325,22 @@ export async function runProviderPrepareDiagnostics({
|
|||
)
|
||||
);
|
||||
|
||||
return {
|
||||
status: hasFailure
|
||||
? 'failed'
|
||||
: hasNotes || dedupedWarnings.length > 0
|
||||
? 'notes'
|
||||
: 'ready',
|
||||
details: [
|
||||
...filteredRuntime.runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
warnings: dedupedWarnings,
|
||||
modelResultsById: selectedModelResultsById,
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: hasFailure
|
||||
? 'failed'
|
||||
: hasNotes || dedupedWarnings.length > 0
|
||||
? 'notes'
|
||||
: 'ready',
|
||||
details: [
|
||||
...filteredRuntime.runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
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 {
|
||||
status: 'failed',
|
||||
details: [
|
||||
normalizeRuntimeFailureDetailLine(
|
||||
failureReason,
|
||||
structuredProviderScopedIssue?.code
|
||||
) ?? failureReason,
|
||||
],
|
||||
warnings: [],
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
details: [
|
||||
normalizeRuntimeFailureDetailLine(
|
||||
failureReason,
|
||||
structuredProviderScopedIssue?.code,
|
||||
providerId
|
||||
) ?? failureReason,
|
||||
],
|
||||
warnings: [],
|
||||
modelResultsById: {},
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
|
|
@ -1323,15 +1452,19 @@ export async function runProviderPrepareDiagnostics({
|
|||
(compatibilityPassedModelIds.length > 1 ||
|
||||
(!hasNonModelScopedDiagnostics && !hasSingleModelFallbackReason))))
|
||||
) {
|
||||
return {
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
batchedModelResult.message
|
||||
),
|
||||
warnings: runtimeWarnings,
|
||||
modelResultsById: {},
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: 'failed',
|
||||
details: createRuntimeFailureDetailLines(
|
||||
runtimeDetailLines,
|
||||
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,13 +1636,16 @@ export async function runProviderPrepareDiagnostics({
|
|||
)
|
||||
);
|
||||
|
||||
return {
|
||||
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
|
||||
details: [
|
||||
...filteredRuntime.runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
warnings: dedupedWarnings,
|
||||
modelResultsById: selectedModelResultsById,
|
||||
};
|
||||
return withSupportDiagnostics(
|
||||
{
|
||||
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
|
||||
details: [
|
||||
...filteredRuntime.runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
warnings: dedupedWarnings,
|
||||
modelResultsById: selectedModelResultsById,
|
||||
},
|
||||
supportDiagnostics
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,336 +1202,348 @@ 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}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2.5 justify-self-end">
|
||||
{showLaunchBadge ? (
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-1"
|
||||
title={runtimeEntry?.runtimeDiagnostic}
|
||||
>
|
||||
<span>
|
||||
{renderLinkifiedText(launchFailureReason, {
|
||||
linkClassName: 'underline underline-offset-2 hover:text-red-200',
|
||||
stopPropagation: true,
|
||||
getLinkLabel: getLaunchFailureLinkLabel,
|
||||
})}
|
||||
</span>
|
||||
{launchVisualState === 'starting_stale' ? (
|
||||
<AlertTriangle
|
||||
className="size-3.5 shrink-0 text-amber-400"
|
||||
aria-label={launchBadgeLabel}
|
||||
/>
|
||||
) : (
|
||||
<SyncedLoader2
|
||||
className="size-3.5 shrink-0 text-[var(--color-text-muted)]"
|
||||
aria-label={launchBadgeLabel}
|
||||
/>
|
||||
)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||
>
|
||||
{launchBadgeLabel}
|
||||
</Badge>
|
||||
{canRelaunchOpenCode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
|
||||
}
|
||||
className="rounded p-1 text-amber-300 transition-colors hover:bg-amber-500/10 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch}
|
||||
onClick={handleRestartMember}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : showFailedLaunchBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
|
||||
>
|
||||
{displayPresenceLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showCopyDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
|
||||
attention
|
||||
/>
|
||||
) : null}
|
||||
{canSkipFailedLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={skippingLaunch ? 'Skipping teammate' : 'Skip for this launch'}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={skippingLaunch || retryingLaunch}
|
||||
onClick={handleSkipFailedLaunch}
|
||||
>
|
||||
{skippingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<Ban className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{skipLaunchError ??
|
||||
(skippingLaunch ? 'Skipping teammate...' : 'Skip for this launch')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canRetryLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
|
||||
}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch || skippingLaunch}
|
||||
onClick={handleRestartMember}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : showSkippedLaunchBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Ban className="size-3.5 shrink-0 text-zinc-400" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 bg-zinc-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-zinc-300"
|
||||
>
|
||||
{displayPresenceLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{spawnEntry?.skipReason ?? 'Skipped for this launch'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{canRetryLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
|
||||
}
|
||||
className="rounded p-1 text-zinc-300 transition-colors hover:bg-zinc-500/10 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch}
|
||||
onClick={handleRestartMember}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : showRuntimeAdvisoryBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<AlertTriangle
|
||||
className={`size-3.5 shrink-0 ${
|
||||
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
|
||||
}`}
|
||||
/>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
|
||||
runtimeAdvisoryTone === 'error'
|
||||
? 'bg-red-500/15 text-red-300'
|
||||
: 'bg-amber-500/15 text-amber-300'
|
||||
}`}
|
||||
title={runtimeAdvisoryTitle}
|
||||
>
|
||||
{runtimeAdvisoryLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{canRelaunchRuntimeAdvisoryOpenCode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
|
||||
}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch}
|
||||
onClick={handleRestartMember}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showRuntimeAdvisoryDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
|
||||
attention
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
) : !activityTask ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
|
||||
title={isRemoved ? 'This member has been removed' : activityTitle}
|
||||
>
|
||||
{isRemoved ? 'removed' : displayPresenceLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
{showStartingSkeleton ? (
|
||||
<div className="shrink-0" aria-hidden="true">
|
||||
<div
|
||||
className="skeleton-shimmer h-[18px] w-[62px] rounded-full border"
|
||||
style={{
|
||||
backgroundColor: 'var(--skeleton-base-dim)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="skeleton-shimmer mx-1 mt-1 h-[2px] w-10 rounded-full"
|
||||
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="shrink-0"
|
||||
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
|
||||
>
|
||||
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
|
||||
</Badge>
|
||||
{totalTasks > 0 && (
|
||||
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* NOTE: lead context bar disabled — usage formula is inaccurate */}
|
||||
</div>
|
||||
)}
|
||||
{!isRemoved && (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSendMessage?.();
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Send message</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAssignTask?.();
|
||||
}}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Assign task</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{canRestoreMember ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={restoringMember ? 'Restoring teammate' : 'Restore teammate'}
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={restoringMember}
|
||||
onClick={handleRestoreMember}
|
||||
>
|
||||
{restoringMember ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<Undo2 className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{restoreMemberError ?? (restoringMember ? 'Restoring teammate...' : 'Restore')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
{showLaunchBadge ? (
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-1"
|
||||
title={runtimeEntry?.runtimeDiagnostic}
|
||||
>
|
||||
{launchVisualState === 'starting_stale' ? (
|
||||
<AlertTriangle
|
||||
className="size-3.5 shrink-0 text-amber-400"
|
||||
aria-label={launchBadgeLabel}
|
||||
/>
|
||||
) : (
|
||||
<SyncedLoader2
|
||||
className="size-3.5 shrink-0 text-[var(--color-text-muted)]"
|
||||
aria-label={launchBadgeLabel}
|
||||
/>
|
||||
)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||
>
|
||||
{launchBadgeLabel}
|
||||
</Badge>
|
||||
{canRelaunchOpenCode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
|
||||
className="rounded p-1 text-amber-300 transition-colors hover:bg-amber-500/10 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch}
|
||||
onClick={handleRestartMember}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : showFailedLaunchBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
|
||||
>
|
||||
{displayPresenceLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showCopyDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
|
||||
/>
|
||||
) : null}
|
||||
{canSkipFailedLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={skippingLaunch ? 'Skipping teammate' : 'Skip for this launch'}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={skippingLaunch || retryingLaunch}
|
||||
onClick={handleSkipFailedLaunch}
|
||||
>
|
||||
{skippingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<Ban className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{skipLaunchError ??
|
||||
(skippingLaunch ? 'Skipping teammate...' : 'Skip for this launch')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canRetryLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch || skippingLaunch}
|
||||
onClick={handleRestartMember}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : showSkippedLaunchBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Ban className="size-3.5 shrink-0 text-zinc-400" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 bg-zinc-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-zinc-300"
|
||||
>
|
||||
{displayPresenceLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{spawnEntry?.skipReason ?? 'Skipped for this launch'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{canRetryLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
|
||||
className="rounded p-1 text-zinc-300 transition-colors hover:bg-zinc-500/10 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch}
|
||||
onClick={handleRestartMember}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : showRuntimeAdvisoryBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<AlertTriangle
|
||||
className={`size-3.5 shrink-0 ${
|
||||
runtimeAdvisoryTone === 'error' ? 'text-red-400' : 'text-amber-400'
|
||||
}`}
|
||||
/>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${
|
||||
runtimeAdvisoryTone === 'error'
|
||||
? 'bg-red-500/15 text-red-300'
|
||||
: 'bg-amber-500/15 text-amber-300'
|
||||
}`}
|
||||
title={runtimeAdvisoryTitle}
|
||||
>
|
||||
{runtimeAdvisoryLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{runtimeAdvisoryTitle ?? runtimeAdvisoryLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{canRelaunchRuntimeAdvisoryOpenCode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch}
|
||||
onClick={handleRestartMember}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showRuntimeAdvisoryDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
) : !activityTask ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
|
||||
title={isRemoved ? 'This member has been removed' : activityTitle}
|
||||
>
|
||||
{isRemoved ? 'removed' : displayPresenceLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
{showStartingSkeleton ? (
|
||||
<div className="shrink-0" aria-hidden="true">
|
||||
<div
|
||||
className="skeleton-shimmer h-[18px] w-[62px] rounded-full border"
|
||||
style={{
|
||||
backgroundColor: 'var(--skeleton-base-dim)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="skeleton-shimmer mx-1 mt-1 h-[2px] w-10 rounded-full"
|
||||
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
{launchFailureReason ? (
|
||||
<div
|
||||
className="shrink-0"
|
||||
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
|
||||
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}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
|
||||
>
|
||||
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
|
||||
</Badge>
|
||||
{totalTasks > 0 && (
|
||||
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* NOTE: lead context bar disabled — usage formula is inaccurate */}
|
||||
<span>
|
||||
{renderLinkifiedText(launchFailureReason, {
|
||||
linkClassName: 'underline underline-offset-2 hover:text-red-200',
|
||||
stopPropagation: true,
|
||||
getLinkLabel: getLaunchFailureLinkLabel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isRemoved && (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSendMessage?.();
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Send message</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAssignTask?.();
|
||||
}}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Assign task</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{canRestoreMember ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={restoringMember ? 'Restoring teammate' : 'Restore teammate'}
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={restoringMember}
|
||||
onClick={handleRestoreMember}
|
||||
>
|
||||
{restoringMember ? (
|
||||
<SyncedLoader2 className="size-3.5" />
|
||||
) : (
|
||||
<Undo2 className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{restoreMemberError ?? (restoringMember ? 'Restoring teammate...' : 'Restore')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
108
src/renderer/store/team/teamAgentRuntimeSnapshotEquality.ts
Normal 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;
|
||||
}
|
||||
21
src/renderer/store/team/teamDataRefreshTimestamps.ts
Normal 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();
|
||||
}
|
||||
39
src/renderer/store/team/teamDataRequestKeys.ts
Normal 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`);
|
||||
}
|
||||
47
src/renderer/store/team/teamDataSelectors.ts
Normal 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;
|
||||
}
|
||||
33
src/renderer/store/team/teamErrorPolicies.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
501
src/renderer/store/team/teamGlobalTaskNotifications.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
219
src/renderer/store/team/teamGraphLayout.ts
Normal 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;
|
||||
}
|
||||
89
src/renderer/store/team/teamLaunchParams.ts
Normal 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
|
||||
);
|
||||
}
|
||||
25
src/renderer/store/team/teamLocalStateEpoch.ts
Normal 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();
|
||||
}
|
||||
64
src/renderer/store/team/teamMemberActivityMeta.ts
Normal 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;
|
||||
}
|
||||
106
src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
39
src/renderer/store/team/teamMemberSpawnStatusBackoff.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
291
src/renderer/store/team/teamMessagesCache.ts
Normal 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;
|
||||
}
|
||||
29
src/renderer/store/team/teamMessagesPanelModePersistence.ts
Normal 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
|
||||
}
|
||||
}
|
||||
45
src/renderer/store/team/teamPendingReplyWaits.ts
Normal 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;
|
||||
}
|
||||
44
src/renderer/store/team/teamProvisioningStateRules.ts
Normal 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;
|
||||
}
|
||||
48
src/renderer/store/team/teamRefreshBurstDiagnostics.ts
Normal 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();
|
||||
}
|
||||
533
src/renderer/store/team/teamResolvedMembers.ts
Normal 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;
|
||||
}
|
||||
190
src/renderer/store/team/teamScopedStateCleanup.ts
Normal 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'
|
||||
>;
|
||||
}
|
||||
61
src/renderer/store/team/teamSnapshotStructuralSharing.ts
Normal 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);
|
||||
}
|
||||
42
src/renderer/store/team/teamToolApprovalSettings.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||