chore: sync current frontend updates

This commit is contained in:
777genius 2026-05-10 21:29:07 +03:00
parent 108838381a
commit cbe8d194ef
81 changed files with 2355 additions and 418 deletions

View file

@ -1,6 +0,0 @@
- generic [ref=e1]:
- img
- img [ref=e2]
- generic [ref=e12]:
- generic [ref=e13]: Agent Teams AI
- generic [ref=e15]: Get more done by doing less.

View file

@ -1,5 +1,5 @@
import { computed } from "vue";
import { supportedLocales, defaultLocale } from "~/data/i18n";
import { supportedLocales, defaultLocale, getLocaleMeta } from "~/data/i18n";
import { getContent } from "~/data/content";
import type { LocaleCode } from "~/data/i18n";
@ -21,15 +21,20 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
const { t, locale } = useI18n();
const route = useRoute();
const config = useRuntimeConfig();
const siteUrl = config.public.siteUrl || "https://example.com";
const siteName = (config as any)?.site?.name || "Agent Teams";
const siteUrl = ((config.public.siteUrl as string) || "https://example.com").replace(/\/+$/, "");
const siteName = "Agent Teams";
const switchLocale = useSwitchLocalePath();
const title = computed(() => t(titleKey));
const description = computed(() => t(descriptionKey));
const canonicalPath = computed(() => route.path);
const canonicalUrl = computed(() => `${siteUrl}${canonicalPath.value}`);
const toSiteUrl = (pathOrUrl: string) => {
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
const path = pathOrUrl === "/" ? "/" : `/${pathOrUrl.replace(/^\/+/, "")}`;
return `${siteUrl}${path}`;
};
const canonicalUrl = computed(() => toSiteUrl(canonicalPath.value));
const resolvedImage = computed<PageSeoImage>(() => {
if (options.image) return options.image;
@ -38,14 +43,14 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
width: 1200,
height: 630,
type: "image/png",
alt: `${siteName} AI agent orchestration`
alt: `${siteName} - AI agent orchestration`
};
});
const resolvedImageUrl = computed(() => {
// Если сборщик вернул относительный путь сделаем абсолютный.
// Если сборщик вернул относительный путь - сделаем абсолютный.
const url = resolvedImage.value.url;
return url.startsWith("http") ? url : new URL(url, siteUrl).toString();
return toSiteUrl(url);
});
useSeoMeta({
@ -57,7 +62,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
ogSiteName: siteName,
ogUrl: canonicalUrl,
ogImage: resolvedImageUrl,
ogImageType: computed(() => resolvedImage.value.type) as any,
ogImageType: computed(() => resolvedImage.value.type),
ogImageWidth: computed(() => (resolvedImage.value.width ? String(resolvedImage.value.width) : undefined)),
ogImageHeight: computed(() => (resolvedImage.value.height ? String(resolvedImage.value.height) : undefined)),
ogImageAlt: computed(() => resolvedImage.value.alt),
@ -72,32 +77,70 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
});
useHead(() => {
const currentLocale = getLocaleMeta(locale.value as LocaleCode);
const links: { rel: string; hreflang?: string; href: string }[] = supportedLocales.map((locale) => {
const path = switchLocale(locale.code) || canonicalPath.value;
return {
rel: "alternate",
hreflang: locale.code,
href: `${siteUrl}${path}`
hreflang: locale.iso,
href: toSiteUrl(path)
};
});
const defaultPath = switchLocale(defaultLocale) || canonicalPath.value;
links.push({ rel: "alternate", hreflang: "x-default", href: `${siteUrl}${defaultPath}` });
links.push({ rel: "alternate", hreflang: "x-default", href: toSiteUrl(defaultPath) });
links.push({ rel: "canonical", href: canonicalUrl.value });
const jsonLd: any[] = [
const ogLocale = currentLocale.iso.replace("-", "_");
const ogAlternateLocales = supportedLocales
.filter((locale) => locale.iso !== currentLocale.iso)
.map((locale) => locale.iso.replace("-", "_"));
const normalizedPath = canonicalPath.value === "/" ? "/" : canonicalPath.value.replace(/\/+$/, "");
const localizedHomePath = currentLocale.code === defaultLocale ? "/" : `/${currentLocale.code}`;
const isHome = normalizedPath === localizedHomePath;
const isDownload = normalizedPath.endsWith("/download");
const organizationId = `${siteUrl}/#organization`;
const websiteId = `${siteUrl}/#website`;
const softwareId = `${siteUrl}/#software`;
const webpageId = `${canonicalUrl.value}#webpage`;
const jsonLd: Record<string, unknown>[] = [
{
"@context": "https://schema.org",
"@type": "WebSite",
"@id": websiteId,
name: siteName,
url: siteUrl
url: siteUrl,
inLanguage: currentLocale.iso,
publisher: { "@id": organizationId }
},
{
"@context": "https://schema.org",
"@type": "WebPage",
"@id": webpageId,
name: title.value,
description: description.value,
url: canonicalUrl.value,
inLanguage: currentLocale.iso,
isPartOf: { "@id": websiteId },
about: { "@id": softwareId },
publisher: { "@id": organizationId },
primaryImageOfPage: {
"@type": "ImageObject",
"@id": `${resolvedImageUrl.value}#primaryimage`,
url: resolvedImageUrl.value,
width: resolvedImage.value.width,
height: resolvedImage.value.height
}
},
{
"@context": "https://schema.org",
"@type": "Organization",
"@id": organizationId,
name: siteName,
url: siteUrl,
logo: `${siteUrl}/favicon.ico`,
logo: toSiteUrl("/logo-192.png"),
sameAs: [
`https://github.com/${config.public.githubRepo}`
]
@ -105,17 +148,22 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
];
// Для главной и страницы скачивания добавим более "вкусную" разметку.
const isDownload = canonicalPath.value.endsWith("/download");
const isHome = canonicalPath.value === "/";
if (isHome || isDownload) {
jsonLd.push({
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"@id": softwareId,
name: siteName,
applicationCategory: "BusinessApplication",
operatingSystem: "Windows, macOS, Linux",
description: description.value,
url: canonicalUrl.value,
mainEntityOfPage: { "@id": webpageId },
author: { "@id": organizationId },
publisher: { "@id": organizationId },
image: resolvedImageUrl.value,
screenshot: toSiteUrl("/screenshots/1.jpg"),
softwareVersion: "latest",
offers: {
"@type": "Offer",
price: "0",
@ -125,13 +173,16 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
});
}
// FAQ rich snippets Google показывает их прямо в выдаче
// FAQ rich snippets - Google показывает их прямо в выдаче
if (isHome) {
const content = getContent(locale.value as LocaleCode);
if (content.faq?.length) {
jsonLd.push({
"@context": "https://schema.org",
"@type": "FAQPage",
"@id": `${canonicalUrl.value}#faq`,
inLanguage: currentLocale.iso,
isPartOf: { "@id": webpageId },
mainEntity: content.faq.map((item) => ({
"@type": "Question",
name: item.question,
@ -146,7 +197,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
}
return {
htmlAttrs: { lang: locale.value || "en" },
htmlAttrs: { lang: currentLocale.iso, dir: "dir" in currentLocale ? currentLocale.dir : "ltr" },
link: links,
meta: [
{ name: "author", content: "Agent Teams" },
@ -154,6 +205,9 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
{ name: "apple-mobile-web-app-title", content: siteName },
{ name: "format-detection", content: "telephone=no" },
{ name: "theme-color", content: "#00f0ff" },
{ name: "googlebot", content: options.robots || "index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" },
{ property: "og:locale", content: ogLocale },
...ogAlternateLocales.map((content) => ({ property: "og:locale:alternate", content })),
{ name: "keywords", content: "claude code, agent teams, AI agents, kanban board, code review, multi-agent orchestration, desktop app, free, open source" }
],
script: jsonLd.map((item) => ({

View file

@ -26,6 +26,16 @@ export const sitemapPages = [
"/download"
] as const;
export type SitemapPagePath = (typeof sitemapPages)[number];
export const getLocaleMeta = (localeCode: LocaleCode) =>
supportedLocales.find((locale) => locale.code === localeCode) ?? supportedLocales[0];
export const getLocalizedPagePath = (page: SitemapPagePath, localeCode: LocaleCode): string => {
if (localeCode === defaultLocale) return page;
return page === "/" ? `/${localeCode}` : `/${localeCode}${page}`;
};
/** Generates i18n routes for a given list of pages */
const buildI18nRoutes = (source: readonly string[]): string[] => {
const routes: string[] = [];

View file

@ -105,7 +105,9 @@
},
"meta": {
"homeTitle": "Agent Teams - تنسيق وكلاء الذكاء الاصطناعي للمطورين",
"homeDescription": "تطبيق سطح مكتب مجاني ومفتوح المصدر لتشكيل فرق وكلاء الذكاء الاصطناعي. لوحة كانبان، مراجعة الكود، تواصل بين الفرق. يعمل محلياً بالكامل."
"homeDescription": "تطبيق سطح مكتب مجاني ومفتوح المصدر لتشكيل فرق وكلاء الذكاء الاصطناعي. لوحة كانبان، مراجعة الكود، تواصل بين الفرق. يعمل محلياً بالكامل.",
"downloadTitle": "تنزيل Agent Teams لنظام macOS وWindows وLinux",
"downloadDescription": "نزّل Agent Teams لنظام macOS وWindows وLinux. تطبيق سطح مكتب مجاني ومفتوح المصدر لفرق وكلاء Claude وCodex وOpenCode."
},
"error": {
"notFoundTitle": "الصفحة غير موجودة",

View file

@ -105,7 +105,9 @@
},
"meta": {
"homeTitle": "Agent Teams - KI-Agenten-Orchestrierung für Entwickler",
"homeDescription": "Kostenlose Open-Source-Desktop-App für KI-Agenten-Teams. Kanban-Board, Code-Review, teamübergreifende Kommunikation. Läuft vollständig lokal."
"homeDescription": "Kostenlose Open-Source-Desktop-App für KI-Agenten-Teams. Kanban-Board, Code-Review, teamübergreifende Kommunikation. Läuft vollständig lokal.",
"downloadTitle": "Agent Teams für macOS, Windows und Linux herunterladen",
"downloadDescription": "Laden Sie Agent Teams für macOS, Windows und Linux herunter. Kostenlose Open-Source-Desktop-App für Claude-, Codex- und OpenCode-Agententeams."
},
"error": {
"notFoundTitle": "Seite nicht gefunden",

View file

@ -105,7 +105,9 @@
},
"meta": {
"homeTitle": "Agent Teams - AI Agent Orchestration for Developers",
"homeDescription": "Free, open-source desktop app for assembling AI agent teams. Kanban board, code review, cross-team communication. Runs entirely locally."
"homeDescription": "Free, open-source desktop app for assembling AI agent teams. Kanban board, code review, cross-team communication. Runs entirely locally.",
"downloadTitle": "Download Agent Teams for macOS, Windows, and Linux",
"downloadDescription": "Download Agent Teams for macOS, Windows, and Linux. Free open-source desktop app for Claude, Codex, and OpenCode agent teams."
},
"error": {
"notFoundTitle": "Page not found",

View file

@ -105,7 +105,9 @@
},
"meta": {
"homeTitle": "Agent Teams - Orquestación de agentes IA para desarrolladores",
"homeDescription": "App de escritorio gratuita y de código abierto para montar equipos de agentes IA. Tablero kanban, revisión de código, comunicación entre equipos. Funciona completamente en local."
"homeDescription": "App de escritorio gratuita y de código abierto para montar equipos de agentes IA. Tablero kanban, revisión de código, comunicación entre equipos. Funciona completamente en local.",
"downloadTitle": "Descargar Agent Teams para macOS, Windows y Linux",
"downloadDescription": "Descarga Agent Teams para macOS, Windows y Linux. App de escritorio gratis y open source para equipos de agentes Claude, Codex y OpenCode."
},
"error": {
"notFoundTitle": "Página no encontrada",

View file

@ -105,7 +105,9 @@
},
"meta": {
"homeTitle": "Agent Teams - Orchestration d'agents IA pour développeurs",
"homeDescription": "Application desktop gratuite et open source pour gérer des équipes d'agents IA. Tableau kanban, revue de code, communication inter-équipes. Fonctionne entièrement en local."
"homeDescription": "Application desktop gratuite et open source pour gérer des équipes d'agents IA. Tableau kanban, revue de code, communication inter-équipes. Fonctionne entièrement en local.",
"downloadTitle": "Télécharger Agent Teams pour macOS, Windows et Linux",
"downloadDescription": "Téléchargez Agent Teams pour macOS, Windows et Linux. Application desktop gratuite et open source pour équipes d'agents Claude, Codex et OpenCode."
},
"error": {
"notFoundTitle": "Page introuvable",

View file

@ -105,7 +105,9 @@
},
"meta": {
"homeTitle": "Agent Teams - डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
"homeDescription": "AI एजेंट टीमें बनाने के लिए मुफ़्त, ओपन-सोर्स डेस्कटॉप ऐप। कानबन बोर्ड, कोड रिव्यू, क्रॉस-टीम संचार। पूरी तरह लोकल चलता है।"
"homeDescription": "AI एजेंट टीमें बनाने के लिए मुफ़्त, ओपन-सोर्स डेस्कटॉप ऐप। कानबन बोर्ड, कोड रिव्यू, क्रॉस-टीम संचार। पूरी तरह लोकल चलता है।",
"downloadTitle": "macOS, Windows और Linux के लिए Agent Teams डाउनलोड करें",
"downloadDescription": "macOS, Windows और Linux के लिए Agent Teams डाउनलोड करें। Claude, Codex और OpenCode एजेंट टीमों के लिए मुफ़्त ओपन-सोर्स डेस्कटॉप ऐप।"
},
"error": {
"notFoundTitle": "पेज नहीं मिला",

View file

@ -105,7 +105,9 @@
},
"meta": {
"homeTitle": "Agent Teams - 開発者向けAIエージェントオーケストレーション",
"homeDescription": "AIエージェントチームを管理する無料のオープンソースデスクトップアプリ。カンバンボード、コードレビュー、チーム間通信。完全にローカルで動作。"
"homeDescription": "AIエージェントチームを管理する無料のオープンソースデスクトップアプリ。カンバンボード、コードレビュー、チーム間通信。完全にローカルで動作。",
"downloadTitle": "macOS、Windows、Linux向けAgent Teamsをダウンロード",
"downloadDescription": "macOS、Windows、Linux向けAgent Teamsをダウンロード。Claude、Codex、OpenCodeエージェントチーム用の無料オープンソースデスクトップアプリ。"
},
"error": {
"notFoundTitle": "ページが見つかりません",

View file

@ -105,7 +105,9 @@
},
"meta": {
"homeTitle": "Agent Teams - Orquestração de agentes IA para desenvolvedores",
"homeDescription": "App desktop gratuito e open source para montar equipes de agentes IA. Quadro kanban, revisão de código, comunicação entre equipes. Roda totalmente local."
"homeDescription": "App desktop gratuito e open source para montar equipes de agentes IA. Quadro kanban, revisão de código, comunicação entre equipes. Roda totalmente local.",
"downloadTitle": "Baixar Agent Teams para macOS, Windows e Linux",
"downloadDescription": "Baixe o Agent Teams para macOS, Windows e Linux. App desktop gratuito e open source para equipes de agentes Claude, Codex e OpenCode."
},
"error": {
"notFoundTitle": "Página não encontrada",

View file

@ -105,7 +105,9 @@
},
"meta": {
"homeTitle": "Agent Teams - Оркестрация ИИ-агентов для разработчиков",
"homeDescription": "Бесплатное десктопное приложение с открытым кодом для управления командами ИИ-агентов. Канбан-доска, код-ревью, межкомандная коммуникация. Работает полностью локально."
"homeDescription": "Бесплатное десктопное приложение с открытым кодом для управления командами ИИ-агентов. Канбан-доска, код-ревью, межкомандная коммуникация. Работает полностью локально.",
"downloadTitle": "Скачать Agent Teams для macOS, Windows и Linux",
"downloadDescription": "Скачайте Agent Teams для macOS, Windows и Linux. Бесплатное open-source приложение для команд агентов Claude, Codex и OpenCode."
},
"error": {
"notFoundTitle": "Страница не найдена",

View file

@ -105,7 +105,9 @@
},
"meta": {
"homeTitle": "Agent Teams - 面向开发者的 AI 智能体编排",
"homeDescription": "免费开源桌面应用,用于组建 AI 智能体团队。看板、代码审查、跨团队通信。完全本地运行。"
"homeDescription": "免费开源桌面应用,用于组建 AI 智能体团队。看板、代码审查、跨团队通信。完全本地运行。",
"downloadTitle": "下载适用于 macOS、Windows 和 Linux 的 Agent Teams",
"downloadDescription": "下载适用于 macOS、Windows 和 Linux 的 Agent Teams。面向 Claude、Codex 和 OpenCode 智能体团队的免费开源桌面应用。"
},
"error": {
"notFoundTitle": "页面未找到",

View file

@ -1,8 +1,9 @@
import vuetify from "vite-plugin-vuetify";
import { generateI18nRoutes, supportedLocales } from "./data/i18n";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const process: any;
declare const process: {
env: Record<string, string | undefined>;
};
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai";
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/agent-teams-ai";
@ -20,6 +21,7 @@ export default defineNuxtConfig({
{ rel: "icon", type: "image/x-icon", href: `${baseURL}favicon.ico` },
{ rel: "icon", type: "image/png", sizes: "32x32", href: `${baseURL}favicon-32.png` },
{ rel: "apple-touch-icon", sizes: "192x192", href: `${baseURL}logo-192.png` },
{ rel: "alternate", type: "text/plain", title: "llms.txt", href: `${baseURL}llms.txt` },
{ rel: "dns-prefetch", href: "https://api.github.com" },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
@ -65,7 +67,8 @@ export default defineNuxtConfig({
routes: [
...generateI18nRoutes(),
"/sitemap.xml",
"/robots.txt"
"/robots.txt",
"/llms.txt"
]
}
},
@ -76,7 +79,7 @@ export default defineNuxtConfig({
},
i18n: {
restructureDir: false,
locales: [...supportedLocales] as any,
locales: [...supportedLocales],
defaultLocale: "en",
strategy: "prefix_except_default",
lazy: true,

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
const { content } = useLandingContent();
usePageSeo("meta.homeTitle", "meta.homeDescription");
usePageSeo("meta.downloadTitle", "meta.downloadDescription");
</script>
<template>

View file

@ -114,17 +114,45 @@ export default defineConfig({
cleanUrls: true,
lastUpdated: true,
sitemap: {
hostname: docsUrl
hostname: docsUrl,
lastmodDateOnly: true
},
head: [
["link", { rel: "icon", type: "image/png", href: `${base}logo-192.png` }],
["meta", { name: "theme-color", content: "#00f0ff" }],
["link", { rel: "canonical", href: docsUrl }],
["meta", { name: "robots", content: "index, follow" }],
["meta", { name: "author", content: "777genius" }],
["meta", { name: "generator", content: "VitePress" }],
["meta", { name: "color-scheme", content: "light dark" }],
["meta", { name: "theme-color", content: "#f8fafc", media: "(prefers-color-scheme: light)" }],
["meta", { name: "theme-color", content: "#0a0a0f", media: "(prefers-color-scheme: dark)" }],
["meta", { property: "og:type", content: "website" }],
["meta", { property: "og:title", content: SITE_TITLE }],
["meta", { property: "og:description", content: SITE_DESCRIPTION }],
["meta", { property: "og:url", content: docsUrl }],
["meta", { property: "og:image", content: `${publicBaseUrl}og-image.png` }],
["meta", { name: "twitter:card", content: "summary_large_image" }]
["meta", { property: "og:image:width", content: "1200" }],
["meta", { property: "og:image:height", content: "630" }],
["meta", { property: "og:site_name", content: "Agent Teams" }],
["meta", { property: "og:locale", content: "en_US" }],
["meta", { name: "twitter:card", content: "summary_large_image" }],
["meta", { name: "twitter:title", content: SITE_TITLE }],
["meta", { name: "twitter:description", content: SITE_DESCRIPTION }],
["meta", { name: "twitter:image", content: `${publicBaseUrl}og-image.png` }],
[
"script",
{ type: "application/ld+json" },
JSON.stringify({
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "Agent Teams",
description: SITE_DESCRIPTION,
url: publicBaseUrl,
applicationCategory: "DeveloperApplication",
operatingSystem: "macOS, Windows, Linux",
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }
})
]
],
vite: {
publicDir: landingPublicDir,
@ -152,7 +180,18 @@ export default defineConfig({
label: "On this page"
},
search: {
provider: "local"
provider: "local",
options: {
translations: {
button: "Search...",
buttonAriaLabel: "Search documentation",
noResultsText: "No results found",
suggestedQueryText: "Try searching for",
reportMissing: "Found a problem? Create an issue",
reportMissingText: "Report missing result",
reportMissingLink: "https://github.com/777genius/agent-teams-ai/issues/new"
}
}
},
nav: rootNav,
sidebar: {
@ -194,6 +233,25 @@ export default defineConfig({
level: [2, 3],
label: "На этой странице"
},
search: {
provider: "local",
options: {
translations: {
button: {
buttonText: "Поиск по документации",
buttonAriaLabel: "поиск по документации"
},
modal: {
noResultsText: "Результаты не найдены",
footer: {
selectText: "для выбора",
navigateText: "для навигации",
closeText: "для закрытия"
}
}
}
}
},
editLink: {
pattern: `https://github.com/${REPO}/edit/main/landing/product-docs/:path`,
text: "Редактировать на GitHub"

View file

@ -48,6 +48,7 @@ const cards = computed(() => {
<span class="docs-card__icon">{{ card.icon }}</span>
<strong>{{ card.title }}</strong>
<span>{{ card.desc }}</span>
<span class="docs-card__arrow" aria-hidden="true"></span>
</a>
</div>
</template>
@ -61,8 +62,10 @@ const cards = computed(() => {
}
.docs-card {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: auto 1fr;
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
column-gap: 12px;
row-gap: 4px;
@ -72,24 +75,27 @@ const cards = computed(() => {
background: var(--at-c-surface-soft);
color: var(--at-c-text);
text-decoration: none !important;
box-shadow: var(--at-shadow-card);
transition:
border-color var(--at-transition-base),
background-color var(--at-transition-base),
transform var(--at-transition-base);
transform var(--at-transition-base),
box-shadow var(--at-transition-base);
}
.docs-card:hover {
border-color: var(--at-c-border-strong);
background: var(--at-glass-bg-hover);
transform: translateY(-2px);
transform: translateY(-3px);
box-shadow: var(--at-shadow-cyan-md);
}
.docs-card__icon {
grid-row: 1 / -1;
display: grid;
place-items: center;
width: 36px;
height: 36px;
width: 40px;
height: 40px;
border-radius: var(--at-radius-md);
background: var(--at-gradient-panel);
color: var(--at-c-cyan);
@ -101,17 +107,44 @@ const cards = computed(() => {
.docs-card strong {
color: var(--at-c-text);
font-size: 15px;
line-height: 1.3;
}
.docs-card span:last-child {
.docs-card > span:nth-of-type(2) {
color: var(--at-c-text-muted);
font-size: 13px;
line-height: 1.45;
}
.docs-card__arrow {
grid-column: 3;
align-self: end;
color: var(--at-c-cyan);
font-family: var(--at-font-mono);
font-size: 16px;
opacity: 0.55;
transform: translateX(-4px);
transition:
opacity var(--at-transition-base),
transform var(--at-transition-base);
}
.docs-card:hover .docs-card__arrow {
opacity: 1;
transform: translateX(0);
}
@media (max-width: 640px) {
.docs-card-grid {
grid-template-columns: 1fr;
}
.docs-card {
grid-template-columns: auto 1fr;
}
.docs-card__arrow {
display: none;
}
}
</style>

View file

@ -18,6 +18,7 @@ const workflowVideoSrc = "https://github.com/user-attachments/assets/35e27989-72
<source :src="workflowVideoSrc" type="video/mp4">
</video>
<div class="docs-hero-visual__wash" />
<div class="docs-hero-visual__glow" />
<div class="docs-hero-visual__edge" />
</div>
</template>
@ -25,11 +26,23 @@ const workflowVideoSrc = "https://github.com/user-attachments/assets/35e27989-72
<style scoped>
.docs-hero-visual {
position: absolute;
inset: -130px -120px -110px;
inset: -140px -126px -116px;
overflow: hidden;
pointer-events: none;
}
.docs-hero-visual::before {
content: "";
position: absolute;
inset: 12% auto auto 8%;
width: 34%;
height: 38%;
border-radius: 999px;
background: radial-gradient(circle, rgba(0, 240, 255, 0.24), transparent 72%);
filter: blur(12px);
opacity: 0.55;
}
.docs-hero-visual__video {
position: absolute;
inset: 0;
@ -37,47 +50,57 @@ const workflowVideoSrc = "https://github.com/user-attachments/assets/35e27989-72
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(1px) saturate(1.22) contrast(1.08);
opacity: 0.95;
filter: blur(0.8px) saturate(1.18) contrast(1.06);
opacity: 0.78;
mix-blend-mode: multiply;
transform: scale(1.04);
transform: scale(1.05);
}
.docs-hero-visual__wash {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, var(--vp-c-bg) 0%, color-mix(in srgb, var(--vp-c-bg) 82%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 8%, transparent) 64%, color-mix(in srgb, var(--vp-c-bg) 26%, transparent) 100%),
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 32%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 44%, transparent) 58%, var(--vp-c-bg) 96%);
linear-gradient(90deg, var(--vp-c-bg) 0%, color-mix(in srgb, var(--vp-c-bg) 86%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 10%, transparent) 66%, color-mix(in srgb, var(--vp-c-bg) 28%, transparent) 100%),
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 42%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 48%, transparent) 58%, var(--vp-c-bg) 96%);
}
.docs-hero-visual__glow {
position: absolute;
inset: auto -8% 10% auto;
width: 44%;
height: 32%;
background: radial-gradient(circle, rgba(255, 0, 255, 0.2), transparent 70%);
filter: blur(18px);
opacity: 0.28;
}
.docs-hero-visual__edge {
position: absolute;
inset: auto 0 0;
height: 42%;
height: 48%;
background: linear-gradient(180deg, transparent, var(--vp-c-bg));
}
.dark .docs-hero-visual__video {
opacity: 0.95;
filter: blur(1px) saturate(1.24) contrast(1.08);
opacity: 0.9;
filter: blur(0.8px) saturate(1.22) contrast(1.08);
mix-blend-mode: normal;
}
.dark .docs-hero-visual__wash {
background:
linear-gradient(90deg, var(--vp-c-bg) 0%, color-mix(in srgb, var(--vp-c-bg) 76%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 8%, transparent) 64%, color-mix(in srgb, var(--vp-c-bg) 34%, transparent) 100%),
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 28%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 52%, transparent) 58%, var(--vp-c-bg) 96%);
linear-gradient(90deg, var(--vp-c-bg) 0%, color-mix(in srgb, var(--vp-c-bg) 78%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 10%, transparent) 64%, color-mix(in srgb, var(--vp-c-bg) 36%, transparent) 100%),
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 30%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 58%, transparent) 58%, var(--vp-c-bg) 96%);
}
@media (max-width: 768px) {
.docs-hero-visual {
inset: -90px -72px -80px;
inset: -78px -58px -64px;
}
.docs-hero-visual__video {
opacity: 0.95;
filter: blur(1px) saturate(1.2) contrast(1.06);
opacity: 0.82;
filter: blur(0.8px) saturate(1.16) contrast(1.04);
}
}
</style>

View file

@ -37,40 +37,61 @@ async function copy() {
.install-block {
display: inline-flex;
align-items: center;
justify-content: space-between;
max-width: 100%;
gap: 12px;
width: 100%;
gap: 14px;
margin: 12px 0 4px;
padding: 12px 16px;
padding: 14px 16px;
border: var(--at-glass-border);
border-radius: var(--at-radius-lg);
border-radius: var(--at-radius-xl);
background: var(--at-c-surface-soft);
color: var(--at-c-text);
cursor: pointer;
box-shadow: var(--at-shadow-card);
transition:
border-color var(--at-transition-base),
background-color var(--at-transition-base),
transform var(--at-transition-base);
transform var(--at-transition-base),
box-shadow var(--at-transition-base);
}
.install-block:hover {
border-color: var(--at-c-border-strong);
background: var(--at-glass-bg-hover);
transform: translateY(-1px);
transform: translateY(-2px);
box-shadow: var(--at-shadow-cyan-md);
}
.install-block code {
overflow: hidden;
min-width: 0;
color: var(--at-c-text);
font-family: var(--at-font-mono);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
text-align: left;
white-space: normal;
}
.install-block span {
flex-shrink: 0;
padding: 6px 10px;
border-radius: var(--at-radius-pill);
background: color-mix(in srgb, var(--at-c-cyan) 12%, transparent);
color: var(--at-c-cyan);
font-family: var(--at-font-mono);
font-size: 12px;
white-space: nowrap;
}
@media (max-width: 640px) {
.install-block {
align-items: flex-start;
flex-direction: column;
}
.install-block span {
align-self: flex-start;
}
}
</style>

View file

@ -23,8 +23,8 @@ defineProps<{
.zoom-image img {
display: block;
width: 100%;
border: var(--at-glass-border);
border-radius: var(--at-radius-xl);
border: var(--at-glass-border);
background: var(--at-c-dark-1);
box-shadow: var(--at-shadow-card);
cursor: zoom-in;
@ -34,6 +34,7 @@ defineProps<{
margin-top: 8px;
color: var(--at-c-text-muted);
font-size: 13px;
line-height: 1.5;
text-align: center;
}
</style>

View file

@ -52,10 +52,14 @@
html {
background: var(--vp-c-bg);
scroll-behavior: smooth;
}
body {
letter-spacing: 0;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
font-feature-settings: "liga" 1, "calt" 1, "kern" 1;
}
.Layout {
@ -70,6 +74,8 @@ body {
z-index: -2;
pointer-events: none;
background:
radial-gradient(circle at 18% -8%, rgba(0, 240, 255, 0.16), transparent 34%),
radial-gradient(circle at 82% 0%, rgba(255, 0, 255, 0.09), transparent 26%),
linear-gradient(180deg, rgba(0, 240, 255, 0.08), transparent 320px),
linear-gradient(135deg, rgba(255, 0, 255, 0.055), transparent 42%),
var(--vp-c-bg);
@ -82,8 +88,10 @@ body {
z-index: -1;
pointer-events: none;
background:
linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--vp-c-bg) 72%, transparent) 58%, var(--vp-c-bg) 100%);
opacity: 0.8;
linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--vp-c-bg) 72%, transparent) 58%, var(--vp-c-bg) 100%),
linear-gradient(90deg, transparent 0, transparent calc(100% - 1px), var(--vp-c-divider) calc(100% - 1px), var(--vp-c-divider) 100%);
background-size: auto, 72px 72px;
opacity: 0.6;
}
.VPNavBar {
@ -91,6 +99,7 @@ body {
background: var(--vp-nav-bg-color) !important;
backdrop-filter: blur(var(--at-blur-md)) !important;
-webkit-backdrop-filter: blur(var(--at-blur-md)) !important;
box-shadow: 0 10px 30px -24px rgba(15, 23, 42, 0.35);
}
.VPNavBarTitle .logo {
@ -102,6 +111,7 @@ body {
.VPNavBarTitle .title {
font-family: var(--at-font-mono);
font-size: 14px;
font-weight: 700;
letter-spacing: 0;
}
@ -114,24 +124,33 @@ body {
.VPHero .text {
max-width: 760px;
text-wrap: balance;
font-size: clamp(2.4rem, 6vw, 4.95rem);
line-height: 0.96;
letter-spacing: -0.05em;
}
.VPHero .tagline {
max-width: 680px;
color: var(--vp-c-text-2);
font-size: 17px;
line-height: 1.7;
text-wrap: balance;
}
.VPHero .actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 28px;
flex-wrap: wrap;
}
.VPHero.has-image {
position: relative;
overflow: hidden;
min-height: 560px;
padding: 96px 24px 76px;
padding: 108px 24px 88px;
}
.VPHero.has-image .container {
@ -145,8 +164,32 @@ body {
.VPHero.has-image .main {
position: relative;
z-index: 2;
max-width: 820px !important;
padding: 38px 0;
max-width: 780px !important;
padding: 30px 32px 32px;
border: 1px solid color-mix(in srgb, var(--vp-c-border) 72%, transparent);
border-radius: 28px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg-elv) 74%, transparent), color-mix(in srgb, var(--vp-c-bg-soft) 56%, transparent)),
color-mix(in srgb, var(--vp-c-bg-elv) 70%, transparent);
box-shadow: var(--at-shadow-cyan-lg);
backdrop-filter: blur(var(--at-blur-lg));
}
.VPHero.has-image .main::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background:
radial-gradient(circle at 20% 0%, rgba(0, 240, 255, 0.14), transparent 42%),
radial-gradient(circle at 82% 12%, rgba(255, 0, 255, 0.08), transparent 30%);
opacity: 0.9;
}
.VPHero.has-image .main > * {
position: relative;
z-index: 1;
}
.VPHero.has-image .image {
@ -220,16 +263,28 @@ body {
}
.VPFeature {
position: relative;
overflow: hidden;
border: var(--at-glass-border) !important;
border-radius: var(--at-radius-xl) !important;
background: var(--vp-c-bg-soft) !important;
backdrop-filter: blur(var(--at-blur-sm));
box-shadow: var(--at-shadow-card);
transition:
transform var(--at-transition-base),
border-color var(--at-transition-base),
box-shadow var(--at-transition-base) !important;
}
.VPFeature::after {
content: "";
position: absolute;
inset: auto 0 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(0, 240, 255, 0.35), transparent);
opacity: 0.8;
}
.VPFeature:hover {
border-color: var(--at-c-border-strong) !important;
box-shadow: var(--at-shadow-cyan-md);
@ -239,9 +294,11 @@ body {
.VPFeature .box {
display: grid !important;
grid-template-columns: 42px 1fr !important;
grid-template-rows: auto auto !important;
grid-template-rows: auto auto auto !important;
column-gap: 12px !important;
align-items: center !important;
min-height: 100%;
padding-bottom: 10px;
}
.VPFeature .box > .icon {
@ -249,8 +306,8 @@ body {
grid-column: 1 !important;
display: grid;
place-items: center;
width: 36px;
height: 36px;
width: 40px;
height: 40px;
margin-bottom: 0 !important;
border-radius: var(--at-radius-md);
background: var(--at-gradient-panel);
@ -262,17 +319,20 @@ body {
.VPFeature .box > .title {
grid-row: 1 !important;
grid-column: 2 !important;
line-height: 1.25;
}
.VPFeature .box > .details {
grid-row: 2 !important;
grid-column: 1 / -1 !important;
margin-top: 4px;
line-height: 1.6;
}
.VPFeature .box > .link-text {
grid-row: 3 !important;
grid-column: 1 / -1 !important;
margin-top: 8px !important;
margin-top: 10px !important;
}
.VPFeature .box > .link-text .link-text-value {
@ -295,11 +355,14 @@ body {
.vp-doc h2,
.vp-doc h3 {
letter-spacing: 0;
text-wrap: balance;
}
.vp-doc h1 {
color: var(--vp-c-text-1);
font-weight: 800;
font-size: clamp(2rem, 3.5vw, 3.25rem);
line-height: 1.05;
}
.dark .vp-doc h1 {
@ -311,11 +374,17 @@ body {
.vp-doc p,
.vp-doc li {
line-height: 1.75;
line-height: 1.8;
color: var(--vp-c-text-1);
}
.vp-doc a {
text-underline-offset: 4px;
transition: color var(--at-transition-fast);
}
.vp-doc a:hover {
color: var(--vp-c-brand-1);
}
.VPNavBarMenuLink[target="_self"].vp-external-link-icon::after,
@ -330,6 +399,14 @@ body {
border-radius: var(--at-radius-xl);
overflow: hidden;
background: var(--vp-c-bg-soft);
box-shadow: var(--at-shadow-card);
border-collapse: separate;
border-spacing: 0;
}
.vp-doc th,
.vp-doc td {
padding: 12px 14px;
}
.vp-doc th {
@ -339,6 +416,14 @@ body {
background: rgba(0, 240, 255, 0.05);
}
.vp-doc tbody tr:nth-child(2n) {
background: color-mix(in srgb, var(--vp-c-bg) 92%, transparent);
}
.vp-doc tbody tr:hover {
background: color-mix(in srgb, var(--vp-c-brand-soft) 30%, transparent);
}
:root:not(.dark) .vp-doc th,
:root:not(.dark) .docs-card__icon,
:root:not(.dark) .install-block span {
@ -353,17 +438,31 @@ body {
.vp-doc :not(pre) > code {
border: 1px solid rgba(0, 240, 255, 0.1);
border-radius: var(--at-radius-xs);
padding: 0.15em 0.38em;
background: color-mix(in srgb, var(--vp-c-brand-soft) 28%, transparent);
color: var(--vp-code-color);
font-size: 0.9em;
}
.vp-doc div[class*="language-"] {
border: 1px solid rgba(0, 240, 255, 0.12);
border: 1px solid color-mix(in srgb, var(--vp-c-border) 86%, transparent);
border-radius: var(--at-radius-xl);
box-shadow: var(--at-shadow-card);
overflow: hidden;
background:
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg-elv) 84%, transparent), color-mix(in srgb, var(--vp-c-bg-soft) 72%, transparent)),
var(--vp-code-block-bg);
}
.vp-doc div[class*="language-"] pre,
.vp-doc div[class*="language-"] code {
font-size: 13px;
line-height: 1.7;
}
.vp-doc .custom-block {
border-radius: var(--at-radius-xl);
box-shadow: var(--at-shadow-card);
}
.vp-doc .custom-block.tip {
@ -401,21 +500,58 @@ body {
z-index: 9999;
}
@media (max-width: 960px) {
.VPHero.has-image .main {
padding: 26px 24px 26px;
border-radius: 24px;
}
}
@media (max-width: 768px) {
.VPHero.has-image {
min-height: 520px;
padding: 72px 20px 56px;
min-height: 0;
padding: 68px 16px 44px;
}
.VPHero.has-image .main {
padding: 24px 0;
padding: 24px 18px 22px;
}
.VPFeature .box {
grid-template-columns: 36px 1fr !important;
grid-template-columns: 38px 1fr !important;
}
.VPFeature .box > .icon {
width: 38px;
height: 38px;
}
.VPHero .text {
font-size: 34px;
font-size: clamp(2rem, 10vw, 3.25rem);
}
.VPHero .tagline {
font-size: 16px;
}
.VPHero .actions {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.VPHero .actions .VPButton {
width: 100%;
max-width: 220px;
}
.vp-doc th,
.vp-doc td {
padding: 10px 12px;
}
.vp-doc div[class*="language-"] pre,
.vp-doc div[class*="language-"] code {
font-size: 12.5px;
}
}

View file

@ -1,3 +1,8 @@
---
title: Agent Workflow Agent Teams Docs
description: Understand task lifecycle, kanban board, messages, task logs, parallel work, live processes, and cross-team communication.
---
# Agent Workflow
Agent Teams makes agent work visible as task state, messages, logs, and reviewable code changes.

View file

@ -1,3 +1,8 @@
---
title: Code Review Agent Teams Docs
description: Inspect task-scoped diffs, accept or reject hunks, leave inline comments, and manage review states from none to approved.
---
# Code Review
Code review in Agent Teams is task-centered. You inspect what changed for a specific task instead of hunting through a large unstructured diff.
@ -19,6 +24,15 @@ Accept small correct changes and reject isolated mistakes without throwing away
If a diff is mostly correct, accept the good hunks first and request changes only for the parts that need fixing. This keeps the board moving.
:::
Use hunk-level decisions for:
| Situation | Action |
| --- | --- |
| Correct scoped change | Accept the hunk |
| Correct idea, wrong file or broad refactor | Reject the hunk and request a narrower fix |
| Unclear behavior change | Comment and ask for verification |
| Generated formatting noise | Reject unless formatting was part of the task |
## Initiating review
1. Open a completed task
@ -27,6 +41,22 @@ If a diff is mostly correct, accept the good hunks first and request changes onl
During review the task is not yet considered done, so other teammates or the lead can still comment on it.
## Review loop
A healthy review loop looks like this:
1. The owner posts a result comment with changed scope and verification
2. The reviewer opens the task diff and checks hunks against the task description
3. The reviewer accepts good hunks, rejects bad hunks, or requests changes
4. The owner fixes only the requested scope and posts a follow-up comment
5. The reviewer approves when the task result and diff match
Example request-changes comment:
```text
Please keep the copy improvements, but revert the unrelated runtime wording in the provider table. Add a docs build result before resubmitting.
```
## Review states
| State | Meaning |
@ -40,6 +70,8 @@ During review the task is not yet considered done, so other teammates or the lea
Teams can review each other's work before you make the final call. This catches obvious regressions and keeps the board honest, but you should still review risky areas yourself.
Agent review is most useful when the reviewer has a clear rubric. For example, tell a reviewer to check only docs clarity, only IPC safety, or only test coverage. Broad "review everything" requests tend to produce weaker feedback.
## Review participants
The team lead is the default reviewer. You can configure additional reviewers in the Kanban settings if you want peers to review each other's work.
@ -58,6 +90,18 @@ Prioritize these areas when reviewing:
Prefer focused verification commands. Broad formatting or lint-fix commands should not be used unless the task explicitly intends broad formatting churn.
Good verification comments include the command and result:
```text
Verified with `pnpm --dir landing docs:build`. Build passed.
```
When verification is skipped, the task comment should say why:
```text
Docs-only wording change. Build not run because the existing dev server was busy; checked Markdown links manually.
```
::: warning Do not auto-format across the whole project
Unless the task is specifically about formatting, avoid running `pnpm lint:fix` on unrelated files. It creates noise in the review surface.
:::

View file

@ -1,3 +1,8 @@
---
title: Create a Team Agent Teams Docs
description: Define roles, assign providers and models, write a team brief, and configure worktree isolation and autonomy levels.
---
# Create a Team
A team is a named group of agents with roles, a lead, a target project, and a coordination prompt.

View file

@ -1,3 +1,8 @@
---
title: Installation Agent Teams Docs
description: Download and install Agent Teams for macOS, Windows, or Linux. Covers packaged builds, source setup, auto-updates, and requirements.
---
# Installation
Agent Teams is distributed as a desktop app for macOS, Windows, and Linux.

View file

@ -1,3 +1,8 @@
---
title: Quickstart Agent Teams Docs
description: Get from a fresh install to a running AI agent team in a few minutes. Covers installation, runtime selection, team creation, and first code review.
---
# Quickstart
This guide gets you from a fresh install to a running team in a few minutes.
@ -18,6 +23,14 @@ Launch the app and select the project directory you want agents to work in. Agen
Pick a Git-tracked project for the best experience. Worktree isolation and diff-based review both rely on Git.
:::
Before launching a team, check that the project has a clean enough baseline:
```bash
git status --short
```
You do not need a perfectly clean tree, but you should know which changes are yours before agents start editing. This makes task diffs and hunk-level review much easier to trust.
## 3. Choose a runtime path
The setup flow auto-detects installed runtimes on your machine. A common first setup is:
@ -34,12 +47,32 @@ Gemini support is in development and will appear in the runtime list when availa
See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider.
To verify the selected runtime outside the app, run the matching version command:
```bash
claude --version
codex --version
opencode --version
```
If the command fails in your terminal, fix the runtime installation or `PATH` first. Team prompts cannot work around a missing binary or missing provider auth.
## 4. Create your first team
Create a team with a lead and one or more specialists. Keep the first team small: one lead, one implementation agent, and one review-oriented agent is enough to validate the workflow.
See [Create a team](/guide/create-team) for the recommended structure and tips.
For the first launch, prefer a team shape like this:
| Member | Responsibility | Notes |
| --- | --- | --- |
| Lead | Split the goal into tasks and coordinate status | Keep on the most reliable provider you have |
| Builder | Implement scoped tasks | Give clear file or feature boundaries |
| Reviewer | Review completed work | Ask it to focus on regressions and missing tests |
Avoid starting with five or more teammates. More agents increase concurrency, logs, provider usage, and conflict risk before you know the setup is healthy.
## 5. Give the lead a concrete goal
Write the goal like you would brief an engineering lead:
@ -48,6 +81,14 @@ Write the goal like you would brief an engineering lead:
Improve the onboarding flow. Split the work into tasks, keep changes small, and ask for review before broad refactors.
```
Good first prompts include concrete scope, safety boundaries, and verification:
```text
Improve the docs quickstart. Keep edits inside landing/product-docs. Add practical examples, preserve existing VitePress syntax, and run the docs build before marking tasks done.
```
Avoid vague prompts such as "make the app better" for the first run. The lead can break down large goals, but better input produces smaller tasks and cleaner review.
The lead creates tasks, assigns work, and coordinates teammates. You can watch progress on the kanban board and intervene with comments or direct messages at any time.
## 6. Review results
@ -56,6 +97,12 @@ Open completed or review-ready tasks, inspect the diff, and accept, reject, or c
See [Code review](/guide/code-review) for the full review workflow.
Before approving the first task, check three things:
1. The task comment explains what changed
2. The changed files match the task scope
3. The verification result is visible in the task comment or logs
## Next steps
- [Create a team](/guide/create-team) — recommended team shapes and brief writing

View file

@ -1,3 +1,13 @@
---
title: Runtime Setup
description: Configure Claude Code, Codex, or OpenCode runtimes and provider authentication for agent teams.
---
---
title: Runtime Setup Agent Teams Docs
description: Configure Claude Code, Codex, or OpenCode runtimes. Covers auth, provider access, multimodel mode, and prelaunch checks.
---
# Runtime Setup
Agent Teams is a coordination layer. The actual model work runs through supported local runtimes and providers.
@ -9,11 +19,22 @@ Before launching a team, make sure:
- The runtime binary is installed and on your `PATH`.
- Your provider account has active access to the model you intend to use.
- The project path exists and is readable.
- The app and your terminal use the same home/config environment when you test auth manually.
::: tip
Start with a single teammate and one provider. Confirm one launch works before adding multimodel lanes.
:::
Quick terminal checks:
```bash
command -v claude
command -v codex
command -v opencode
```
Run the command for the runtime you plan to use. If it prints nothing, install the runtime or fix `PATH` before launching a team.
## Supported paths
| Path | Default CLI | Typical providers | Use when |
@ -24,6 +45,8 @@ Start with a single teammate and one provider. Confirm one launch works before a
The app detects supported runtimes and guides setup from the UI when possible.
Gemini appears in some internal provider lists but is currently hidden from the main team creation UI while its launch experience is still marked in development.
## Provider access
Agent Teams has no paid tier of its own. You bring the provider access you already have: subscriptions, local runtime auth, or API keys depending on the path you choose.
@ -47,6 +70,8 @@ Then verify the CLI is reachable:
claude --version
```
If the packaged app reports "not logged in" while your terminal works, compare the `$HOME` and `PATH` seen by the app with the terminal you used for login. The auth diagnostic log described in [Troubleshooting](/guide/troubleshooting#auth-diagnostic-log) is the best starting point.
### Codex
Install and authenticate via OpenAI's CLI flow:
@ -55,6 +80,14 @@ Install and authenticate via OpenAI's CLI flow:
codex login
```
Then verify the runtime is reachable:
```bash
codex --version
```
Codex-native launches use Codex account state and model catalog data when available. If a model is missing from the UI, refresh provider status before editing team prompts.
### OpenCode
Create or edit `~/.opencode/config.json` (or the equivalent path on your platform) with the provider key you want:
@ -71,6 +104,16 @@ Create or edit `~/.opencode/config.json` (or the equivalent path on your platfor
Use the exact provider name that OpenCode expects. If you set a custom provider name, double-check it against the provider ID you use in the model string (for example `openrouter/moonshotai/kimi-k2.6` would use the `openrouter` block).
Example model strings:
| Model string | Provider block that must exist |
| --- | --- |
| `openrouter/moonshotai/kimi-k2.6` | `openrouter` |
| `openai/gpt-5.4` | `openai` |
| `anthropic/claude-sonnet-4-6` | `anthropic` |
If OpenCode launches but a teammate never becomes deliverable, inspect lane evidence before assuming the model ignored the prompt. See [Troubleshooting](/guide/troubleshooting#opencode-registered-but-bootstrap-unconfirmed).
## Multimodel mode
Multimodel mode can route work through many provider backends via OpenCode-compatible configuration. Use it when you need provider flexibility or want teammates to use different model lanes.
@ -79,6 +122,16 @@ Multimodel mode can route work through many provider backends via OpenCode-compa
Each teammate can use a different `providerId` + `model` pair. In the team edit UI, expand member options to override the global defaults.
:::
A conservative multimodel setup:
| Role | Provider | Why |
| --- | --- | --- |
| Lead | Claude or Codex | Keep coordination on the provider you trust most |
| Builder | OpenCode | Use broad model routing for implementation work |
| Reviewer | Claude, Codex, or a second OpenCode model | Separate review judgment from the builder lane |
Avoid mixing many unfamiliar providers in the first launch. Confirm one small task per lane before assigning broad work.
## Prelaunch checklist
Before launching a team:
@ -96,3 +149,13 @@ Switch when the current path is blocked by model availability, rate limits, prov
::: warning Treat setup errors as setup problems
If auth fails, a model name is rejected, or the runtime binary cannot be found, fix the setup first. Do not change team prompts or project code to work around a runtime configuration issue.
:::
Use this decision table:
| Symptom | Better first action |
| --- | --- |
| Binary not found | Fix installation or `PATH` |
| Login works in terminal but not app | Check Electron auth diagnostic log and environment |
| Model rejected | Verify exact model id in the provider runtime |
| Repeated 429s | Lower concurrency or switch model/provider |
| OpenCode lane stuck | Inspect lane manifest and `opencode-sessions.json` |

View file

@ -1,3 +1,13 @@
---
title: Troubleshooting
description: Fix launch failures, missing agent replies, rate limits, auth issues, and lane bootstrap problems in Agent Teams.
---
---
title: Troubleshooting Agent Teams Docs
description: Fix team launch issues, missing agent replies, rate limits, CLI auth problems, and lane bootstrap stalls with local diagnostics.
---
# Troubleshooting
Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, and provider limits.

View file

@ -1,4 +1,6 @@
---
title: Agent Teams Docs Run AI Agent Teams from a Local Desktop App
description: Documentation for Agent Teams, a free desktop app for AI agent orchestration. Create teams, watch work on a kanban board, review code changes, and coordinate Claude, Codex, OpenCode, and multimodel workflows.
layout: home
hero:
name: Agent Teams Docs

View file

@ -1,3 +1,8 @@
---
title: Concepts
description: Core vocabulary for Agent Teams — teams, leads, teammates, tasks, kanban, inboxes, runtimes, and review.
---
# Concepts
This page defines the core terms used across Agent Teams. Use it as the shared vocabulary for the app, task board, messages, and review flow.
@ -6,16 +11,20 @@ This page defines the core terms used across Agent Teams. Use it as the shared v
A team is a named group of agents attached to one project path. It has a lead, optional teammates, runtime/provider settings, prompts, inboxes, tasks, and local launch state.
## Lead
## Lead {#lead}
The lead is the coordinator for the team. It turns a user goal into tasks, assigns or redirects teammates, tracks blockers, asks for review, and keeps work moving through the board.
[Teammate →](#teammate)
Lead messages use a different delivery path from teammate messages: the app relays lead inbox entries into the lead runtime, while teammates read their own inbox files between turns.
## Teammate
## Teammate {#teammate}
A teammate is a non-lead agent in the team. Teammates usually own focused roles such as builder, reviewer, researcher, or tester. A teammate can receive direct messages, task assignments, task comments, and review requests.
[Lead ↑](#lead)
## Task
A task is the durable unit of work. It has an id, status, owner, description, comments, logs, attachments, task references, and reviewable changes.

View file

@ -1,3 +1,18 @@
---
title: FAQ
description: Frequently asked questions about Agent Teams — pricing, model access, runtimes, privacy, review, and troubleshooting.
---
---
title: FAQ Agent Teams Docs
description: Frequently asked questions about pricing, model access, runtime setup, data privacy, worktree isolation, and code review.
---
---
title: FAQ Agent Teams Docs
description: Frequently asked questions about Agent Teams — pricing, model access, runtimes, privacy, review, and debugging.
---
# FAQ
## Is Agent Teams free?
@ -18,6 +33,18 @@ Not always. The app guides runtime detection and setup from the UI. Some paths s
OpenCode setup is separate from Claude Code and Codex setup. If a launch fails, check runtime status and provider auth before changing the team prompt.
## How do I check whether a runtime is ready?
Run the runtime command in a terminal first:
```bash
claude --version
codex --version
opencode --version
```
Then confirm provider auth for the path you selected. If the command or auth check fails outside Agent Teams, fix setup before launching a team.
## Does it upload my code to Agent Teams servers?
No. Agent Teams is not a cloud code-sync service. Provider-backed model calls may receive prompt context depending on your selected runtime.
@ -34,6 +61,14 @@ Prompt context, selected file contents, tool results, command output, task text,
Yes. Agents can message teammates, comment on tasks, coordinate across teams, and use task references to keep conversations attached to work.
## What should I put in the first team prompt?
Give the lead a concrete outcome, file or feature boundaries, risk limits, and verification expectations. For example:
```text
Improve the docs quickstart. Keep edits inside landing/product-docs, add practical examples, and run the docs build before marking work done.
```
## Can I review code before accepting it?
Yes. The review flow is built around task-scoped diffs and hunk-level decisions.
@ -46,6 +81,10 @@ An Agent Block is hidden agent-only text wrapped in markers such as `<info_for_a
Solo mode is a one-agent team. It is useful for smaller tasks and lower coordination overhead.
## Should I use worktree isolation?
Use it when multiple OpenCode teammates may edit the same Git project in parallel. It reduces file conflicts, but it requires a Git-tracked project and currently applies to OpenCode members.
## Can different teammates use different providers?
Yes, provider/model settings can be carried per team member when the selected runtime path supports them. OpenCode is the main path for broad multi-provider routing.

View file

@ -1,3 +1,18 @@
---
title: Privacy and Local Data
description: What the Agent Teams desktop app stores locally and what data may leave your machine through provider-backed models.
---
---
title: Privacy and Local Data Agent Teams Docs
description: What Agent Teams stores locally, what may leave your machine through provider-backed model calls, and practical privacy guidance.
---
---
title: Privacy and Local Data Agent Teams Docs
description: What the Agent Teams desktop app stores locally and what data may leave your machine through provider-backed model calls.
---
# Privacy and Local Data
Agent Teams is local-first, but the selected runtime/provider path still matters. This page describes what the desktop app stores locally and what may leave your machine when agents call provider-backed models.
@ -33,6 +48,15 @@ However, when an agent asks a provider-backed model to work, prompt context, sel
Provider authentication, provider-side retention, training, logging, regional processing, and billing are governed by the provider/runtime you choose. Review those policies for sensitive projects.
Common examples:
| Action | Data that may be sent through the runtime/provider |
| --- | --- |
| Asking an agent to edit a file | The task prompt, relevant file contents, tool results, and command output |
| Attaching a screenshot | The attachment content and surrounding task/comment text |
| Asking for a code review | Diff context, selected files, comments, and verification logs |
| Debugging a failing command | Error output, stack traces, and referenced source snippets |
## What the app does not guarantee
- It cannot guarantee that provider-backed model calls never receive private code.
@ -51,6 +75,14 @@ Provider authentication, provider-side retention, training, logging, regional pr
- Check generated prompts, task descriptions, and attached files before asking agents to work on confidential material.
- Use provider/model paths that match your privacy requirements.
Before using Agent Teams on a sensitive repository:
1. Remove secrets from the working tree and task attachments
2. Choose the runtime/provider path you are allowed to use
3. Start with low autonomy and small tasks
4. Review task prompts and generated comments before expanding scope
5. Keep logs local unless you intentionally share them for support
## Open source model
The app itself is open source and free. You can inspect how local orchestration, task tracking, inboxes, runtime diagnostics, and review flows work in the repository.

View file

@ -1,3 +1,18 @@
---
title: Providers and Runtimes
description: Supported runtime paths, provider ids, model ids, multi-provider strategy, and capability checks in Agent Teams.
---
---
title: Providers and Runtimes Agent Teams Docs
description: Supported runtime paths (Claude Code, Codex, OpenCode), provider IDs, model naming, multi-provider strategies, and capability checks.
---
---
title: Providers and Runtimes Agent Teams Docs
description: Supported runtime paths, provider ids, model ids, multi-provider strategy, and capability checks in Agent Teams.
---
# Providers and Runtimes
Agent Teams separates orchestration from model access. The app manages teams, tasks, messages, launch state, and review UI; the selected runtime/provider path performs the actual model work.
@ -33,6 +48,8 @@ The runtime provides:
| Codex | Codex / OpenAI-backed models | Codex-native workflows | Uses Codex runtime integration and Codex auth/account state where available. Some diagnostics are different from Claude transcripts. |
| OpenCode | OpenCode-managed model routing | Multi-provider teams and broad model coverage | OpenCode can route through many model providers. Agent Teams treats OpenCode lanes as runtime-specific evidence and avoids guessing when lane identity is ambiguous. |
Gemini provider ids exist in internal configuration paths, but Gemini is currently hidden from the main team creation UI while the launch flow remains in development.
## Provider ids
The app currently recognizes these provider ids in team/runtime configuration:
@ -46,6 +63,20 @@ The app currently recognizes these provider ids in team/runtime configuration:
Do not read this table as a guarantee that every provider is authenticated, installed, or available for every model on every machine. The runtime status and capability checks are the source of truth for a given launch.
## Model ids
Model ids are passed to the selected runtime. Agent Teams does not rewrite a provider's model catalog into a universal naming scheme.
Examples:
| Provider path | Example model id | Notes |
| --- | --- | --- |
| Claude Code | `opus`, `sonnet`, or a full Claude model id | Availability depends on Claude Code and account access |
| Codex | `gpt-5.4`, `gpt-5.3-codex` | Availability comes from Codex account/runtime state |
| OpenCode | `openrouter/moonshotai/kimi-k2.6` | Prefix must match an OpenCode provider configuration |
If a model name is rejected, verify it directly in the runtime/provider first. Changing a team brief cannot make an unavailable model launch.
## Multi-provider strategy
Agent Teams keeps orchestration provider-aware but not provider-owned:
@ -55,6 +86,14 @@ Agent Teams keeps orchestration provider-aware but not provider-owned:
- model availability, auth, rate limits, and tool behavior remain runtime/provider responsibilities
- OpenCode is the broadest routing path when you want one team to use multiple provider/model lanes
Recommended patterns:
| Pattern | When it helps | Risk |
| --- | --- | --- |
| One provider for all members | First launch, sensitive repos, simplest debugging | Shared rate limits can stop the whole team |
| Strong lead + cheaper builders | Keep planning/review reliable while reducing implementation cost | Builder output may need stricter review |
| Separate builder and reviewer models | Catch model-specific blind spots | More setup and attribution to inspect |
## Provider costs
Agent Teams is free and open source. Provider usage is governed by the runtime/provider you select: subscription limits, API keys, account auth, rate limits, and provider policies all remain external to the app.
@ -65,6 +104,15 @@ During setup, the app may perform access and capability checks. This helps detec
Capability checks can report that a provider exists but is not authenticated, that a model list is unavailable, that a runtime path is missing, or that a specific extension capability is unsupported. Treat those results as setup diagnostics, not task failures.
Typical setup fixes:
| Check result | What to do |
| --- | --- |
| Runtime missing | Install the CLI or fix `PATH` |
| Provider unauthenticated | Run the provider login flow or add the required API key |
| Model unavailable | Pick a model visible in that runtime's model list |
| Capability unsupported | Use another runtime path for that teammate |
## Limits to expect
- Runtime support does not mean equal feature parity across Claude Code, Codex, and OpenCode.

View file

@ -1,3 +1,9 @@
---
title: Работа агентов Документация Agent Teams
description: Жизненный цикл задач, канбан-доска, сообщения, task logs, параллельная работа, live processes и cross-team communication.
lang: ru-RU
---
# Работа агентов
Agent Teams делает работу агентов видимой через task state, messages, logs и reviewable code changes.

View file

@ -1,3 +1,9 @@
---
title: Код-ревью Документация Agent Teams
description: Проверять diff по задаче, принимать или отклонять hunks, оставлять inline comments и управлять review states от none до approved.
lang: ru-RU
---
# Код-ревью
Код-ревью в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff.
@ -19,6 +25,15 @@
Если diff в основном верен, сначала примите хорошие hunks и запросите правки только для проблемных частей. Это не даёт доске застопориться.
:::
Используйте hunk-level decisions так:
| Situation | Action |
| --- | --- |
| Correct scoped change | Accept hunk |
| Correct idea, wrong file или broad refactor | Reject hunk и request narrower fix |
| Unclear behavior change | Comment и попросить verification |
| Generated formatting noise | Reject, если formatting не был частью task |
## Инициирование ревью
1. Откройте завершённую задачу
@ -27,6 +42,22 @@
Во время ревью задача ещё не считается завершённой, поэтому другие teammates или lead могут всё ещё комментировать её.
## Review loop
Здоровый review loop выглядит так:
1. Owner публикует result comment с changed scope и verification
2. Reviewer открывает task diff и сверяет hunks с task description
3. Reviewer принимает хорошие hunks, отклоняет плохие или requests changes
4. Owner исправляет только requested scope и пишет follow-up comment
5. Reviewer approves, когда task result и diff совпадают
Пример request-changes comment:
```text
Please keep the copy improvements, but revert the unrelated runtime wording in the provider table. Add a docs build result before resubmitting.
```
## Состояния ревью
| Состояние | Значение |
@ -40,6 +71,8 @@
Команды могут ревьюить работу друг друга до вашего финального решения. Это ловит очевидные регрессии, но risky areas всё равно стоит проверять вручную.
Agent review полезнее, когда reviewer получает ясный rubric. Например, попросите проверить только docs clarity, только IPC safety или только test coverage. Широкие запросы "review everything" обычно дают более слабый feedback.
## Участники ревью
Team lead — ревьюер по умолчанию. Вы можете настроить дополнительных ревьюеров в настройках Kanban, если хотите, чтобы peers ревьюили работу друг друга.
@ -58,6 +91,18 @@ Team lead — ревьюер по умолчанию. Вы можете наст
Лучше запускать focused verification commands. Broad formatting или lint-fix команды не стоит использовать, если задача явно не про форматирование.
Хорошие verification comments включают command и result:
```text
Verified with `pnpm --dir landing docs:build`. Build passed.
```
Если verification пропущена, task comment должен объяснять почему:
```text
Docs-only wording change. Build not run because the existing dev server was busy; checked Markdown links manually.
```
::: warning Не запускайте автоформатирование по всему проекту
Если задача не специфически про форматирование, избегайте `pnpm lint:fix` на несвязанных файлах. Это создаёт шум в review surface.
:::

View file

@ -1,3 +1,9 @@
---
title: Создание команды Документация Agent Teams
description: Определить роли, назначить провайдеры и модели, написать brief команды и настроить worktree isolation и уровни autonomy.
lang: ru-RU
---
# Создание команды
Команда — это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt.

View file

@ -1,3 +1,9 @@
---
title: Установка Документация Agent Teams
description: Скачать и установить Agent Teams для macOS, Windows или Linux. Готовые сборки, запуск из source, автообновления и требования.
lang: ru-RU
---
# Установка
Agent Teams распространяется как desktop-приложение для macOS, Windows и Linux.

View file

@ -1,3 +1,9 @@
---
title: Быстрый старт Документация Agent Teams
description: От свежей установки до запущенной команды AI-агентов за несколько минут. Установка, выбор рантайма, создание команды и первый код-ревью.
lang: ru-RU
---
# Быстрый старт
Этот гайд проводит от свежей установки до первой запущенной команды за несколько минут.
@ -18,6 +24,14 @@
Выберите проект под Git — так вы получите лучший опыт. Изоляция через worktree и ревью по diff зависят от Git.
:::
Перед запуском команды проверьте базовое состояние проекта:
```bash
git status --short
```
Не обязательно иметь идеально чистое дерево, но важно понимать, какие изменения уже были вашими до старта агентов. Так проще доверять task diffs и hunk-level review.
## 3. Выберите runtime
Мастер настройки автоматически определит установленные рантаймы на вашей машине. Стандартные варианты:
@ -34,12 +48,32 @@
Подробная настройка каждого провайдера — в разделе [Настройка рантайма](/ru/guide/runtime-setup).
Чтобы проверить выбранный runtime вне приложения, запустите соответствующую команду версии:
```bash
claude --version
codex --version
opencode --version
```
Если команда падает в терминале, сначала исправьте runtime installation или `PATH`. Team prompts не смогут компенсировать отсутствующий binary или provider auth.
## 4. Создайте первую команду
Начните с маленькой команды: lead, implementation agent и review-oriented agent. Этого достаточно, чтобы проверить workflow без лишнего шума.
Рекомендованная структура и советы — в разделе [Создание команды](/ru/guide/create-team).
Для первого запуска используйте примерно такую структуру:
| Member | Responsibility | Notes |
| --- | --- | --- |
| Lead | Делит цель на tasks и координирует status | Держите на самом надёжном provider |
| Builder | Реализует scoped tasks | Дайте понятные file или feature boundaries |
| Reviewer | Проверяет завершённую работу | Попросите фокусироваться на regressions и missing tests |
Не начинайте сразу с пяти и более teammates. Больше агентов означает больше concurrency, logs, provider usage и риск конфликтов до того, как вы убедились, что setup здоровый.
## 5. Дайте lead-агенту конкретную цель
Пишите задачу как инженерному лиду:
@ -48,6 +82,14 @@
Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими рефакторингами.
```
Хороший первый prompt содержит scope, safety boundaries и verification:
```text
Improve the docs quickstart. Keep edits inside landing/product-docs. Add practical examples, preserve existing VitePress syntax, and run the docs build before marking tasks done.
```
Избегайте размытых prompts вроде "make the app better" для первого запуска. Lead может дробить большие цели, но хороший input даёт более маленькие tasks и чище review.
Lead создаёт задачи, назначает работу и координирует teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages в любой момент.
## 6. Проверьте результат
@ -56,6 +98,12 @@ Lead создаёт задачи, назначает работу и коорд
Полный процесс ревью — в разделе [Код-ревью](/ru/guide/code-review).
Перед approval первой task проверьте три вещи:
1. Task comment объясняет, что изменилось
2. Изменённые файлы совпадают со scope задачи
3. Verification result виден в task comment или logs
## Дальше
- [Создание команды](/ru/guide/create-team) — рекомендованные структуры и написание brief

View file

@ -1,3 +1,14 @@
---
title: Настройка рантайма
description: Настройте Claude Code, Codex или OpenCode рантаймы и аутентификацию провайдеров для команд агентов.
---
---
title: Настройка рантайма Документация Agent Teams
description: Конфигурация Claude Code, Codex или OpenCode. Авторизация, провайдеры, multimodel mode и предзапусковые проверки.
lang: ru-RU
---
# Настройка рантайма
Agent Teams — coordination layer. Model work выполняется через локальные runtimes и providers.
@ -9,11 +20,22 @@ Agent Teams — coordination layer. Model work выполняется через
- Runtime binary установлен и находится в `PATH`.
- Ваш аккаунт провайдера имеет доступ к выбранной модели.
- Путь к проекту существует и доступен для чтения.
- Приложение и терминал используют одинаковое home/config окружение, когда вы вручную проверяете auth.
::: tip
Начните с одного teammate и одного провайдера. Подтвердите запуск одной команды, прежде чем добавлять multimodel lanes.
:::
Быстрые terminal checks:
```bash
command -v claude
command -v codex
command -v opencode
```
Запускайте команду для runtime, который планируете использовать. Если вывода нет, установите runtime или исправьте `PATH` до запуска команды.
## Поддерживаемые пути
| Путь | CLI по умолчанию | Типичные провайдеры | Когда использовать |
@ -24,6 +46,8 @@ Agent Teams — coordination layer. Model work выполняется через
Приложение по возможности определяет доступные runtimes и ведёт настройку через UI.
Gemini встречается во внутренних provider lists, но сейчас скрыт из основного team creation UI, пока launch experience отмечен как in development.
## Доступ к провайдеру
У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: подписка, локальная авторизация рантайма или API-ключи в зависимости от выбранного пути.
@ -47,6 +71,8 @@ claude login
claude --version
```
Если packaged app пишет "not logged in", хотя терминал работает, сравните `$HOME` и `PATH`, которые видит приложение, с терминалом, где вы делали login. Auth diagnostic log из [Диагностики](/ru/guide/troubleshooting#диагностический-лог-авторизации) - лучшая стартовая точка.
### Codex
Установите и авторизуйтесь через CLI OpenAI:
@ -55,6 +81,14 @@ claude --version
codex login
```
Затем проверьте, что runtime доступен:
```bash
codex --version
```
Codex-native launches используют Codex account state и model catalog data, когда они доступны. Если model не видна в UI, обновите provider status до редактирования team prompts.
### OpenCode
Создайте или отредактируйте `~/.opencode/config.json` (или эквивалентный путь на вашей платформе):
@ -71,6 +105,16 @@ codex login
Используйте точное имя провайдера, которое ожидает OpenCode. Если вы используете кастомное имя, убедитесь, что оно совпадает с provider ID в строке модели (например, `openrouter/moonshotai/kimi-k2.6` использует блок `openrouter`).
Примеры model strings:
| Model string | Provider block, который должен существовать |
| --- | --- |
| `openrouter/moonshotai/kimi-k2.6` | `openrouter` |
| `openai/gpt-5.4` | `openai` |
| `anthropic/claude-sonnet-4-6` | `anthropic` |
Если OpenCode запускается, но teammate не становится deliverable, сначала смотрите lane evidence, а не предполагаете, что model проигнорировала prompt. См. [Диагностика](/ru/guide/troubleshooting#opencode-registered-но-bootstrap-не-подтверждён).
## Multimodel-режим
Multimodel-режим может направлять работу через разные provider backends в OpenCode-совместимой конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates.
@ -79,6 +123,16 @@ Multimodel-режим может направлять работу через р
Каждый teammate может использовать свою пару `providerId` + `model`. В UI редактирования команды разверните опции member, чтобы переопределить глобальные значения.
:::
Консервативный multimodel setup:
| Role | Provider | Why |
| --- | --- | --- |
| Lead | Claude или Codex | Держит coordination на самом надёжном provider |
| Builder | OpenCode | Даёт broad model routing для implementation work |
| Reviewer | Claude, Codex или второй OpenCode model | Отделяет review judgment от builder lane |
Не смешивайте много незнакомых providers в первом launch. Подтвердите одну маленькую task на каждую lane до broad work.
## Чеклист перед запуском
Перед запуском команды:
@ -96,3 +150,13 @@ Multimodel-режим может направлять работу через р
::: warning Считайте ошибки setup setup-проблемами
Если auth падает, имя модели отклонено или binary runtime не найден — сначала исправьте настройку. Не меняйте team prompts или код проекта, чтобы обойти проблему конфигурации рантайма.
:::
Используйте эту таблицу решений:
| Symptom | Better first action |
| --- | --- |
| Binary not found | Исправить installation или `PATH` |
| Login работает в terminal, но не app | Проверить Electron auth diagnostic log и environment |
| Model rejected | Проверить точный model id в provider runtime |
| Repeated 429s | Уменьшить concurrency или сменить model/provider |
| OpenCode lane stuck | Проверить lane manifest и `opencode-sessions.json` |

View file

@ -1,3 +1,14 @@
---
title: Диагностика
description: Исправление ошибок запуска, пропавших ответов агентов, rate limits, проблем auth и lane bootstrap в Agent Teams.
---
---
title: Диагностика Документация Agent Teams
description: Решение проблем с запуском команд, отсутствующими ответами агентов, rate limits, CLI auth и lane bootstrap stalls через локальные диагностики.
lang: ru-RU
---
# Диагностика
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing и provider limits.

View file

@ -1,4 +1,6 @@
---
title: Документация Agent Teams Запускайте команды AI-агентов из локального desktop-приложения
description: Документация Agent Teams, бесплатного desktop-приложения для оркестрации AI-агентов. Создавайте команды, наблюдайте за канбан-доской, ревьюйте изменения и координируйте Claude, Codex, OpenCode и multimodel workflows.
layout: home
hero:
name: Документация Agent Teams

View file

@ -1,3 +1,14 @@
---
title: Концепции
description: Основной словарь Agent Teams — команды, lead-агенты, teammates, задачи, канбан, inboxes, рантаймы и review.
---
---
title: Концепции Документация Agent Teams
description: Основные термины Agent Teams: teams, leads, teammates, tasks, kanban, inboxes, agent blocks, context phases, runtimes, providers.
lang: ru-RU
---
# Концепции
Основные термины Agent Teams. Эта страница задаёт общий словарь для приложения, доски задач, сообщений и review flow.
@ -6,16 +17,20 @@
Team - именованная группа агентов, привязанная к одному project path. У команды есть lead, опциональные teammates, настройки runtime/provider, prompts, inboxes, tasks и локальное состояние запуска.
## Lead
## Lead {#lead}
Lead - координатор команды. Он превращает цель пользователя в tasks, назначает или перенаправляет teammates, отслеживает blockers, запрашивает review и двигает работу по board.
[Teammate →](#teammate)
Сообщения lead доставляются иначе, чем сообщения teammate: приложение ретранслирует записи inbox в lead runtime, а teammates читают свои inbox-файлы между turns.
## Teammate
## Teammate {#teammate}
Teammate - не-lead агент в команде. Обычно teammate отвечает за сфокусированную роль: builder, reviewer, researcher или tester. Teammate может получать direct messages, task assignments, task comments и review requests.
[Lead ↑](#lead)
## Task
Task - долговечная единица работы. У неё есть id, status, owner, description, comments, logs, attachments, task references и reviewable changes.

View file

@ -1,3 +1,14 @@
---
title: FAQ
description: Часто задаваемые вопросы об Agent Teams — цена, доступ к моделям, рантаймы, приватность, ревью и диагностика.
---
---
title: FAQ Документация Agent Teams
description: Часто задаваемые вопросы о цене, доступе к моделям, настройке рантаймов, приватности данных, worktree isolation и код-ревью.
lang: ru-RU
---
# FAQ
## Agent Teams бесплатный?
@ -18,6 +29,18 @@
OpenCode setup отделён от Claude Code и Codex setup. Если launch fails, сначала проверьте runtime status и provider auth, а не меняйте team prompt.
## Как проверить, что runtime готов?
Сначала запустите runtime command в терминале:
```bash
claude --version
codex --version
opencode --version
```
Затем проверьте provider auth для выбранного пути. Если command или auth check не работает вне Agent Teams, исправьте setup до запуска команды.
## Приложение загружает мой код на серверы Agent Teams?
Нет. Agent Teams не является cloud code-sync сервисом. Но provider-backed model calls могут получать prompt context в зависимости от выбранного runtime.
@ -34,6 +57,14 @@ Prompt context, selected file contents, tool results, command output, task text,
Да. Агенты могут писать teammates, комментировать tasks, координироваться между teams и использовать task references, чтобы разговор оставался привязанным к работе.
## Что написать в первый team prompt?
Дайте lead конкретный outcome, file или feature boundaries, risk limits и verification expectations. Например:
```text
Improve the docs quickstart. Keep edits inside landing/product-docs, add practical examples, and run the docs build before marking work done.
```
## Можно ревьюить код перед принятием?
Да. Review flow построен вокруг task-scoped diffs и hunk-level decisions.
@ -46,6 +77,10 @@ Agent Block - скрытый agent-only text в маркерах вроде `<in
Solo mode - команда из одного агента. Подходит для небольших задач и меньшего coordination overhead.
## Стоит ли включать worktree isolation?
Включайте, когда несколько OpenCode teammates могут параллельно редактировать один Git project. Это снижает file conflicts, но требует Git-tracked project и сейчас применяется к OpenCode members.
## Могут ли разные teammates использовать разных providers?
Да, provider/model settings могут задаваться per team member, если выбранный runtime path это поддерживает. OpenCode - основной путь для широкой multi-provider routing.

View file

@ -1,3 +1,14 @@
---
title: Приватность и локальные данные
description: Что desktop-приложение Agent Teams хранит локально и какие данные могут покинуть машину через provider-backed models.
---
---
title: Приватность и локальные данные Документация Agent Teams
description: Что Agent Teams хранит локально, что может покинуть машину через provider-backed model calls, и практические рекомендации по приватности.
lang: ru-RU
---
# Приватность и локальные данные
Agent Teams local-first, но выбранный runtime/provider path всё равно важен. Эта страница описывает, что desktop app хранит локально и что может покинуть машину, когда agents вызывают provider-backed models.
@ -33,6 +44,15 @@ Agent Teams сам по себе не является cloud code-sync серв
Provider authentication, provider-side retention, training, logging, regional processing и billing регулируются выбранным provider/runtime. Для sensitive projects проверяйте их policies.
Типичные примеры:
| Action | Data, которое может уйти через runtime/provider |
| --- | --- |
| Попросить агента редактировать file | Task prompt, relevant file contents, tool results и command output |
| Прикрепить screenshot | Attachment content и связанный task/comment text |
| Попросить code review | Diff context, selected files, comments и verification logs |
| Debug failing command | Error output, stack traces и referenced source snippets |
## Чего app не гарантирует
- App не может гарантировать, что provider-backed model calls никогда не получат private code.
@ -51,6 +71,14 @@ Provider authentication, provider-side retention, training, logging, regional pr
- Проверяйте generated prompts, task descriptions и attached files перед работой с confidential material.
- Выбирайте provider/model paths, которые соответствуют вашим privacy requirements.
Перед использованием Agent Teams на sensitive repository:
1. Уберите secrets из working tree и task attachments
2. Выберите runtime/provider path, который вам разрешено использовать
3. Начните с low autonomy и small tasks
4. Review task prompts и generated comments до расширения scope
5. Храните logs локально, если не собираетесь специально делиться ими для support
## Open source
Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking, inboxes, runtime diagnostics и review flows.

View file

@ -1,3 +1,14 @@
---
title: Провайдеры и рантаймы
description: Поддерживаемые runtime paths, provider ids, model ids, multi-provider стратегия и capability checks в Agent Teams.
---
---
title: Провайдеры и рантаймы Документация Agent Teams
description: Поддерживаемые runtime paths (Claude Code, Codex, OpenCode), provider IDs, модели, multi-provider стратегии и capability checks.
lang: ru-RU
---
# Провайдеры и рантаймы
Agent Teams отделяет orchestration от model access. Приложение управляет teams, tasks, messages, launch state и review UI; выбранный runtime/provider path выполняет реальную model work.
@ -33,6 +44,8 @@ Runtime отвечает за:
| Codex | Codex / OpenAI-backed models | Для Codex-native workflows | Использует Codex runtime integration и Codex auth/account state, когда они доступны. Часть diagnostics отличается от Claude transcripts. |
| OpenCode | OpenCode-managed model routing | Для multi-provider teams и широкой model coverage | OpenCode может маршрутизировать через множество model providers. Agent Teams считает OpenCode lanes runtime-specific evidence и не угадывает attribution при ambiguous lane identity. |
Gemini provider ids существуют во внутренних configuration paths, но Gemini сейчас скрыт из основного team creation UI, пока launch flow остаётся in development.
## Provider ids
В team/runtime configuration приложение сейчас распознаёт такие provider ids:
@ -46,6 +59,20 @@ Runtime отвечает за:
Эта таблица не гарантирует, что каждый provider authenticated, installed или доступен для каждой модели на каждой машине. Runtime status и capability checks - source of truth для конкретного launch.
## Model ids
Model ids передаются в выбранный runtime. Agent Teams не переписывает provider model catalog в универсальную naming scheme.
Примеры:
| Provider path | Example model id | Notes |
| --- | --- | --- |
| Claude Code | `opus`, `sonnet` или full Claude model id | Availability зависит от Claude Code и account access |
| Codex | `gpt-5.4`, `gpt-5.3-codex` | Availability приходит из Codex account/runtime state |
| OpenCode | `openrouter/moonshotai/kimi-k2.6` | Prefix должен совпадать с OpenCode provider configuration |
Если model name rejected, сначала проверьте его прямо в runtime/provider. Изменение team brief не заставит unavailable model запуститься.
## Multi-provider strategy
Agent Teams остаётся provider-aware, но не provider-owned:
@ -55,6 +82,14 @@ Agent Teams остаётся provider-aware, но не provider-owned:
- model availability, auth, rate limits и tool behavior остаются ответственностью runtime/provider
- OpenCode - основной путь, когда одной team нужны разные provider/model lanes
Рекомендуемые patterns:
| Pattern | When it helps | Risk |
| --- | --- | --- |
| One provider for all members | First launch, sensitive repos, simplest debugging | Shared rate limits могут остановить всю team |
| Strong lead + cheaper builders | Planning/review остаются надёжными, implementation дешевле | Builder output может требовать более строгого review |
| Separate builder and reviewer models | Ловит model-specific blind spots | Больше setup и attribution для проверки |
## Стоимость providers
Agent Teams бесплатен и open source. Provider usage зависит от выбранного runtime/provider: subscription limits, API keys, account auth, rate limits и provider policies остаются внешними для приложения.
@ -65,6 +100,15 @@ Agent Teams бесплатен и open source. Provider usage зависит о
Capability checks могут показать, что provider существует, но не authenticated; model list недоступен; runtime path отсутствует; или конкретная extension capability unsupported. Считайте это setup diagnostics, а не task failures.
Типичные setup fixes:
| Check result | What to do |
| --- | --- |
| Runtime missing | Установить CLI или исправить `PATH` |
| Provider unauthenticated | Запустить provider login flow или добавить нужный API key |
| Model unavailable | Выбрать model, которая видна в model list этого runtime |
| Capability unsupported | Использовать другой runtime path для этого teammate |
## Ожидаемые ограничения
- Runtime support не означает одинаковый feature parity для Claude Code, Codex и OpenCode.

View file

@ -0,0 +1,52 @@
import { defaultLocale, getLocalizedPagePath, sitemapPages, supportedLocales } from "~/data/i18n";
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
export default defineEventHandler((event) => {
const config = useRuntimeConfig();
const siteUrl = trimTrailingSlash(
(config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai"
);
const githubRepo = (config.public.githubRepo as string) || "777genius/agent-teams-ai";
const githubUrl = `https://github.com/${githubRepo}`;
const releasesUrl = `${githubUrl}/releases`;
const toSiteUrl = (path: string) => `${siteUrl}${path === "/" ? "/" : `/${path.replace(/^\/+/, "")}`}`;
setHeader(event, "content-type", "text/plain; charset=utf-8");
const localizedPages = sitemapPages
.flatMap((page) =>
supportedLocales.map((locale) => {
const path = getLocalizedPagePath(page, locale.code);
const label = page === "/" ? "Landing" : "Download";
return `- ${label} (${locale.iso}): ${toSiteUrl(path)}`;
})
)
.join("\n");
return `# Agent Teams
> Agent Teams is a free, open-source local desktop app for orchestrating AI agent teams across Claude, Codex, and OpenCode. It provides a live kanban board, agent-to-agent messaging, task logs, code review, downloads for macOS, Windows, and Linux, and local-first control.
## Primary URLs
- Homepage (${defaultLocale}): ${toSiteUrl("/")}
- Download: ${toSiteUrl("/download")}
- Documentation: ${toSiteUrl("/docs/")}
- Documentation llms.txt: ${toSiteUrl("/docs/llms.txt")}
- GitHub repository: ${githubUrl}
- Releases: ${releasesUrl}
- Sitemap: ${toSiteUrl("/sitemap.xml")}
## Localized landing pages
${localizedPages}
## Useful context
- The app itself is free and open source.
- Provider/runtime access is supplied by the user through supported local runtimes or provider accounts.
- The product is local-first: coordination state and project workflows are designed to run on the user's machine.
- Key workflows: create an agent team, assign or let agents create tasks, watch progress on a kanban board, inspect task logs, and review code changes.
`;
});

View file

@ -1,6 +1,6 @@
export default defineEventHandler((event) => {
const config = useRuntimeConfig();
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
const siteUrl = ((config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai").replace(/\/+$/, "");
setHeader(event, "content-type", "text/plain; charset=utf-8");

View file

@ -1,4 +1,5 @@
import { generateSitemapRoutes } from "~/data/i18n";
import { defaultLocale, getLocalizedPagePath, sitemapPages, supportedLocales } from "~/data/i18n";
import { screenshots } from "~/data/screenshots";
const escapeXml = (value: string) =>
value
@ -12,17 +13,38 @@ const buildDate = new Date().toISOString().split("T")[0];
export default defineEventHandler((event) => {
const config = useRuntimeConfig();
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
const siteUrl = ((config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai").replace(/\/+$/, "");
const toSiteUrl = (path: string) => `${siteUrl}${path === "/" ? "/" : `/${path.replace(/^\/+/, "")}`}`;
const homeImagePaths = ["og-image.png", ...screenshots.map((screenshot) => screenshot.path)];
const downloadImagePaths = ["og-image.png", "logo-192.png"];
setHeader(event, "content-type", "application/xml; charset=utf-8");
const routes = generateSitemapRoutes();
const entries = sitemapPages.flatMap((page) =>
supportedLocales.map((locale) => ({
path: getLocalizedPagePath(page, locale.code),
page
}))
);
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${routes
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
${entries
.map(
(path) =>
` <url>\n <loc>${escapeXml(`${siteUrl}${path}`)}</loc>\n <lastmod>${buildDate}</lastmod>\n </url>`
({ path, page }) => {
const alternates = supportedLocales
.map((locale) => {
const href = toSiteUrl(getLocalizedPagePath(page, locale.code));
return ` <xhtml:link rel="alternate" hreflang="${escapeXml(locale.iso)}" href="${escapeXml(href)}" />`;
})
.join("\n");
const imagePaths = page === "/" ? homeImagePaths : downloadImagePaths;
const images = imagePaths
.map((imagePath) => ` <image:image>\n <image:loc>${escapeXml(toSiteUrl(imagePath))}</image:loc>\n </image:image>`)
.join("\n");
const defaultHref = toSiteUrl(getLocalizedPagePath(page, defaultLocale));
return ` <url>\n <loc>${escapeXml(toSiteUrl(path))}</loc>\n${alternates}\n <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(defaultHref)}" />\n${images}\n <lastmod>${buildDate}</lastmod>\n </url>`;
}
)
.join("\n")}
</urlset>

View file

@ -40,11 +40,15 @@ export function drawAgents(
const color = node.color ?? getStateColor(node.state);
const isSelected = node.id === selectedId;
const isHovered = node.id === hoveredId;
const hasErrorException = node.exceptionTone === 'error';
ctx.save();
ctx.globalAlpha = opacity;
if (simplify) {
if (hasErrorException) {
drawExceptionGlow(ctx, x, y, r, time, true);
}
drawHexagon(ctx, x, y, r);
ctx.fillStyle = isSelected ? 'rgba(100, 200, 255, 0.15)' : COLORS.nodeInterior;
ctx.fill();
@ -63,6 +67,9 @@ export function drawAgents(
// Outer glow
drawGlow(ctx, x, y, r, color);
if (hasErrorException) {
drawExceptionGlow(ctx, x, y, r, time);
}
// Hexagonal body with interior fill
drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered);
@ -250,6 +257,47 @@ function drawExceptionPip(
ctx.restore();
}
function drawExceptionGlow(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
r: number,
time: number,
simplified = false
): void {
const pulse = 0.5 + 0.5 * Math.sin(time * 4.2);
const glowAlpha = simplified ? 0.12 : 0.16 + pulse * 0.08;
const strokeAlpha = simplified ? 0.7 : 0.5 + pulse * 0.24;
const outerR = r + (simplified ? 13 : 20);
const ringR = r + (simplified ? 4 : 7);
const arcR = r + (simplified ? 9 : 13);
const errorColor = '#ef4444';
ctx.save();
const grad = ctx.createRadialGradient(x, y, r * 0.6, x, y, outerR);
grad.addColorStop(0, hexWithAlpha(errorColor, glowAlpha));
grad.addColorStop(0.68, hexWithAlpha(errorColor, glowAlpha * 0.55));
grad.addColorStop(1, hexWithAlpha(errorColor, 0));
ctx.beginPath();
ctx.arc(x, y, outerR, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = hexWithAlpha(errorColor, strokeAlpha);
ctx.lineWidth = simplified ? 2 : 2.4;
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, arcR, time * 1.35, time * 1.35 + Math.PI * 1.3);
ctx.strokeStyle = hexWithAlpha('#f87171', simplified ? 0.78 : 0.62 + pulse * 0.22);
ctx.lineWidth = simplified ? 1.4 : 2;
ctx.lineCap = 'round';
ctx.stroke();
ctx.restore();
}
function drawLaunchStage(
ctx: CanvasRenderingContext2D,
x: number,

View file

@ -150,8 +150,6 @@ const STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS = 96;
const STRICT_SMALL_TEAM_RADIUS_EPSILON = 0.5;
const STRICT_SMALL_TEAM_RADIUS_STEP = 24;
const GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT = 2;
const GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT = 3;
const GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT = 6;
const GRID_UNDER_LEAD_LEAD_GAP = 77.7;
const GRID_UNDER_LEAD_ROW_GAP = 77.7;
const ROW_ORBIT_MIN_OWNER_COUNT = 6;
@ -1602,9 +1600,7 @@ function planGridUnderLeadOwnerSlots(
}
function getGridUnderLeadColumnCount(ownerCount: number): number {
return ownerCount === GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT
? GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT
: GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT;
return Math.min(ownerCount, GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT);
}
function shouldUseStrictSmallTeamCardinalLayout(

View file

@ -19,6 +19,7 @@ import {
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
import {
classifyIdleNotificationText,
getIdleGraphLabel,
@ -131,7 +132,7 @@ export class TeamGraphAdapter {
provisioningProgress?: TeamProvisioningProgress | null,
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
layoutMode: GraphLayoutMode = 'radial',
layoutMode: GraphLayoutMode = DEFAULT_TEAM_GRAPH_LAYOUT_MODE,
gridOwnerOrder?: readonly string[],
activeTaskLogActivity?: Record<string, true>
): GraphDataPort {
@ -290,7 +291,7 @@ export class TeamGraphAdapter {
data: TeamGraphData,
teamName: string,
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
mode: GraphLayoutMode = 'radial',
mode: GraphLayoutMode = DEFAULT_TEAM_GRAPH_LAYOUT_MODE,
gridOwnerOrder?: readonly string[]
): GraphLayoutPort {
const ownerOrder: string[] = [];

View file

@ -14,9 +14,11 @@ import {
selectTeamDataForName,
selectTeamMessages,
} from '@renderer/store/slices/teamSlice';
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
import { useShallow } from 'zustand/react/shallow';
import { GRAPH_STABLE_SLOT_LAYOUT_VERSION } from '../../core/domain/graphOwnerIdentity';
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
@ -42,7 +44,19 @@ function subscribeNoop(): () => void {
}
function emptyGraphData(teamName: string): GraphDataPort {
return { nodes: [], edges: [], particles: [], teamName, isAlive: false };
return {
nodes: [],
edges: [],
particles: [],
teamName,
isAlive: false,
layout: {
version: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
mode: DEFAULT_TEAM_GRAPH_LAYOUT_MODE,
ownerOrder: [],
slotAssignments: {},
},
};
}
export function useTeamGraphAdapter(
@ -191,7 +205,7 @@ export function useTeamGraphAdapter(
provisioningProgress,
memberSpawnSnapshot,
effectiveSlotAssignments,
graphLayoutMode ?? 'radial',
graphLayoutMode ?? DEFAULT_TEAM_GRAPH_LAYOUT_MODE,
gridOwnerOrder,
activeTaskLogActivity
);

View file

@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useStore } from '@renderer/store';
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
@ -45,7 +46,7 @@ export function useTeamGraphSurfaceActions(teamName: string): {
? parseGraphMemberNodeId(payload.displacedNodeId, teamName)
: null;
const store = useStore.getState();
if ((store.graphLayoutModeByTeam[teamName] ?? 'radial') !== 'radial') {
if ((store.graphLayoutModeByTeam[teamName] ?? DEFAULT_TEAM_GRAPH_LAYOUT_MODE) !== 'radial') {
return;
}
if (displacedStableOwnerId && payload.displacedAssignment) {
@ -72,7 +73,10 @@ export function useTeamGraphSurfaceActions(teamName: string): {
}
const store = useStore.getState();
if ((store.graphLayoutModeByTeam[teamName] ?? 'radial') !== 'grid-under-lead') {
if (
(store.graphLayoutModeByTeam[teamName] ?? DEFAULT_TEAM_GRAPH_LAYOUT_MODE) !==
'grid-under-lead'
) {
return;
}

View file

@ -386,10 +386,24 @@ const MemberPopoverContent = ({
: node.state === 'error'
? 'bg-red-400'
: 'bg-zinc-600');
const hasErrorException = node.exceptionTone === 'error';
const statusBadgeClass = hasErrorException
? 'border-red-500/60 bg-red-500/10 text-red-300 shadow-[0_0_12px_rgba(239,68,68,0.22)]'
: '';
const showExceptionBadge = node.exceptionLabel && node.exceptionLabel !== statusLabel;
return (
<div className="min-w-[200px] max-w-[280px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
<div
className="min-w-[200px] max-w-[280px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl"
style={
hasErrorException
? {
borderColor: 'rgba(239, 68, 68, 0.42)',
boxShadow: '0 18px 38px rgba(0, 0, 0, 0.45), 0 0 26px rgba(239, 68, 68, 0.2)',
}
: undefined
}
>
{/* Header: avatar + name */}
<div className="flex items-center gap-3">
<div className="relative shrink-0">
@ -422,7 +436,7 @@ const MemberPopoverContent = ({
{/* Status badges */}
<div className="mt-2 flex flex-wrap gap-1">
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
<Badge variant="outline" className={`px-1.5 py-0 text-[10px] ${statusBadgeClass}`}>
{statusLabel}
</Badge>
{node.kind === 'lead' && (
@ -447,7 +461,7 @@ const MemberPopoverContent = ({
variant="outline"
className={`px-1.5 py-0 text-[10px] ${
node.exceptionTone === 'error'
? 'border-red-500/30 text-red-400'
? 'border-red-500/60 bg-red-500/10 text-red-300 shadow-[0_0_12px_rgba(239,68,68,0.22)]'
: 'border-amber-500/30 text-amber-400'
}`}
>

View file

@ -142,6 +142,7 @@ import {
} from '../services/team/memberUpdateNotifications';
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import { TeamConfigReader } from '../services/team/TeamConfigReader';
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore';
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
@ -330,6 +331,16 @@ function noteHeavyTeamDataWorkerFallback(operation: string): void {
);
}
function invalidateTeamRosterSnapshotCaches(teamName: string): void {
TeamConfigReader.invalidateTeam(teamName);
const teamDataService = getTeamDataService();
teamDataService.invalidateMessageFeed(teamName);
teamDataService.invalidateTeamRuntimeAdvisories(teamName);
const workerClient = getTeamDataWorkerClient();
workerClient.invalidateTeamConfig(teamName);
workerClient.invalidateMemberRuntimeAdvisory(teamName);
}
async function getDurableLeadTeammateRoster(
teamName: string,
leadName: string
@ -1692,6 +1703,9 @@ async function rollbackOpenCodeLiveRosterMutation(options: {
previousMembers,
previousMembersMeta,
});
if (metadataRestored) {
invalidateTeamRosterSnapshotCaches(teamName);
}
const detachNames = Array.from(
new Set(detachOpenCodeMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
@ -2002,10 +2016,15 @@ async function handleCreateTeam(
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
launchIoGovernor?.noteLaunchIntent(validation.value.teamName, 'create');
try {
return await getTeamProvisioningService().createTeam(validation.value, (progress) => {
const response = await getTeamProvisioningService().createTeam(
validation.value,
(progress) => {
launchIoGovernor?.noteProvisioningProgress(progress);
sendProvisioningProgress(progressTargetWindow, progress);
});
}
);
invalidateTeamRosterSnapshotCaches(validation.value.teamName);
return response;
} catch (error) {
noteLaunchIntentFailed(validation.value.teamName, 'create');
throw error;
@ -2145,10 +2164,15 @@ async function handleLaunchTeam(
return wrapTeamHandler('create', async () => {
launchIoGovernor?.noteLaunchIntent(tn, 'draft-launch');
try {
return await getTeamProvisioningService().createTeam(createRequest, (progress) => {
const response = await getTeamProvisioningService().createTeam(
createRequest,
(progress) => {
launchIoGovernor?.noteProvisioningProgress(progress);
sendProvisioningProgress(progressTargetWindow, progress);
});
}
);
invalidateTeamRosterSnapshotCaches(tn);
return response;
} catch (error) {
noteLaunchIntentFailed(tn, 'draft-launch');
throw error;
@ -2195,7 +2219,7 @@ async function handleLaunchTeam(
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
launchIoGovernor?.noteLaunchIntent(validatedTeamName.value!, 'launch');
try {
return await getTeamProvisioningService().launchTeam(
const response = await getTeamProvisioningService().launchTeam(
{
teamName: validatedTeamName.value!,
cwd,
@ -2222,6 +2246,8 @@ async function handleLaunchTeam(
sendProvisioningProgress(progressTargetWindow, progress);
}
);
invalidateTeamRosterSnapshotCaches(validatedTeamName.value!);
return response;
} catch (error) {
noteLaunchIntentFailed(validatedTeamName.value!, 'launch');
throw error;
@ -4182,6 +4208,7 @@ async function handleAddMember(
model: typeof model === 'string' ? model.trim() || undefined : undefined,
effort: effortValidation.value,
});
invalidateTeamRosterSnapshotCaches(tn);
// If team is alive, notify the lead to spawn the new teammate
if (isTeamAlive) {
@ -4401,7 +4428,7 @@ async function handleReplaceMembers(
: [];
await teamDataService.replaceMembers(tn, { members });
teamDataService.invalidateMessageFeed(tn);
invalidateTeamRosterSnapshotCaches(tn);
if (!isTeamAlive) {
return;
@ -4499,6 +4526,7 @@ async function handleRemoveMember(
(member) => member.name.trim().toLowerCase() === name.trim().toLowerCase()
);
await teamDataService.removeMember(tn, name);
invalidateTeamRosterSnapshotCaches(tn);
// Notify the lead about removed member
if (isTeamAlive) {
@ -4614,6 +4642,7 @@ async function handleUpdateMemberRole(
);
if (changed) {
invalidateTeamRosterSnapshotCaches(tn);
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const oldDesc = oldRole ? `"${oldRole}"` : 'none';

View file

@ -7,7 +7,6 @@ import {
type OpenCodePromptDeliveryLedgerRecord,
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
import {
classifyOpenCodeRuntimeDeliveryReasonCode,
decideOpenCodeRuntimeDeliveryAdvisory,
getOpenCodeRuntimeDeliveryRecordTimeMs,
isPotentialOpenCodeRuntimeDeliveryError,
@ -21,6 +20,7 @@ import {
getOpenCodeLaneScopedRuntimeFilePath,
readOpenCodeRuntimeLaneIndex,
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { classifyRuntimeDiagnostic } from './runtime/RuntimeDiagnosticClassifier';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
@ -701,7 +701,7 @@ export class TeamMemberRuntimeAdvisoryService {
observedAt: new Date(observedAt).toISOString(),
retryUntil: new Date(retryUntil).toISOString(),
retryDelayMs: retryInMs,
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(message),
reasonCode: classifyRuntimeDiagnostic(message).reasonCode,
...(message ? { message } : {}),
};
} catch {
@ -753,7 +753,7 @@ export class TeamMemberRuntimeAdvisoryService {
return {
kind: 'api_error',
observedAt: new Date(observedAt).toISOString(),
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(message || parsed.error),
reasonCode: classifyRuntimeDiagnostic(message || parsed.error).reasonCode,
...(message ? { message } : {}),
...(statusMatch ? { statusCode: Number(statusMatch[1]) } : {}),
};

View file

@ -1,8 +1,8 @@
import {
isActionRequiredOpenCodeRuntimeDeliveryReason,
normalizeOpenCodeRuntimeDeliveryDiagnostic,
selectOpenCodeRuntimeDeliveryReason,
} from './OpenCodeRuntimeDeliveryDiagnostics';
import { classifyRuntimeDiagnostic } from '../../runtime/RuntimeDiagnosticClassifier';
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
import type {
@ -32,83 +32,6 @@ export interface OpenCodeRuntimeDeliveryAdvisoryDecision {
nextReviewAt?: string;
}
const QUOTA_EXHAUSTED_TOKENS = [
'exhausted your capacity',
'capacity exceeded',
'quota exceeded',
'quota exhausted',
'insufficient credits',
'key limit exceeded',
'total limit',
] as const;
const RATE_LIMITED_TOKENS = [
'rate limit',
'too many requests',
'429',
'model cooldown',
'cooling down',
] as const;
const AUTH_ERROR_TOKENS = [
'auth_unavailable',
'no auth available',
'authentication_failed',
'unauthorized',
'forbidden',
'invalid api key',
'authentication',
'api key',
'does not have access',
'please run /login',
] as const;
const CODEX_NATIVE_TIMEOUT_TOKENS = ['codex native exec timed out'] as const;
const NETWORK_ERROR_TOKENS = [
'timeout',
'timed out',
'network',
'connection',
'econn',
'enotfound',
'fetch failed',
] as const;
const PROVIDER_OVERLOADED_TOKENS = [
'overloaded',
'temporarily unavailable',
'service unavailable',
'503',
] as const;
const PROTOCOL_PROOF_MISSING_TOKENS = [
'non_visible_tool_without_task_progress',
'visible_reply_still_required',
'visible_reply_ack_only_still_requires_answer',
'plain_text_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid',
'visible_reply_missing_task_refs',
'visible_reply_missing_task_refs_after_merge',
'visible_reply_task_refs_merge_failed',
'did not create a visible reply',
'did not create a visible message_send reply',
'did not create a visible reply or task progress proof',
'without the required relayofmessageid correlation',
'without the required taskrefs metadata',
'could not be verified',
'no visible reply has been found yet',
] as const;
const DEFERRED_GENERIC_DELIVERY_TOKENS = [
...PROTOCOL_PROOF_MISSING_TOKENS,
'empty_assistant_turn',
'empty assistant turn',
'prompt_delivered_no_assistant_message',
'accepted the prompt, but no assistant turn was recorded',
'opencode runtime delivery did not complete',
'opencode message delivery observe bridge failed',
'opencode bridge command timed out',
'opencode app mcp was reattached before message delivery',
'reattached stale opencode app mcp server',
'recreated opencode session before message delivery',
'opencode session reconcile skipped because the stored session is stale',
] as const;
const HARD_RUNTIME_RESPONSE_STATES = new Set([
'session_error',
'tool_error',
@ -116,43 +39,10 @@ const HARD_RUNTIME_RESPONSE_STATES = new Set([
'reconcile_failed',
]);
function includesAnyToken(value: string, tokens: readonly string[]): boolean {
return tokens.some((token) => value.includes(token));
}
function normalizeForClassification(message: string | null | undefined): string {
return normalizeOpenCodeRuntimeDeliveryDiagnostic(message)?.toLowerCase() ?? '';
}
export function classifyOpenCodeRuntimeDeliveryReasonCode(
message: string | undefined
): MemberRuntimeAdvisory['reasonCode'] {
const normalized = normalizeForClassification(message);
if (!normalized) {
return 'unknown';
}
if (includesAnyToken(normalized, QUOTA_EXHAUSTED_TOKENS)) {
return 'quota_exhausted';
}
if (includesAnyToken(normalized, RATE_LIMITED_TOKENS)) {
return 'rate_limited';
}
if (includesAnyToken(normalized, AUTH_ERROR_TOKENS)) {
return 'auth_error';
}
if (includesAnyToken(normalized, CODEX_NATIVE_TIMEOUT_TOKENS)) {
return 'codex_native_timeout';
}
if (includesAnyToken(normalized, NETWORK_ERROR_TOKENS)) {
return 'network_error';
}
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
return 'provider_overloaded';
}
if (includesAnyToken(normalized, PROTOCOL_PROOF_MISSING_TOKENS)) {
return 'protocol_proof_missing';
}
return 'backend_error';
return classifyRuntimeDiagnostic(message).reasonCode;
}
export function getOpenCodeRuntimeDeliveryRecordTimeMs(
@ -222,8 +112,8 @@ export function isProofOnlyOpenCodeRuntimeDeliveryReason(
export function isDeferredGenericOpenCodeRuntimeDeliveryReason(
reason: string | null | undefined
): boolean {
const normalized = normalizeForClassification(reason);
return Boolean(normalized) && includesAnyToken(normalized, DEFERRED_GENERIC_DELIVERY_TOKENS);
const classification = classifyRuntimeDiagnostic(reason);
return Boolean(classification.normalizedMessage) && classification.generic;
}
export function isHardOpenCodeRuntimeDeliveryReason(input: {

View file

@ -1,85 +1,28 @@
import {
classifyRuntimeDiagnostic,
selectRuntimeDiagnosticClassification,
} from '../../runtime/RuntimeDiagnosticClassifier';
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
const SECRET_VALUE_PATTERNS = [
/\bsk-[A-Z0-9_-]{12,}\b/gi,
/\b[A-Z0-9_-]*api[_-]?key[A-Z0-9_-]*[=:]\s*['"]?[^'"\s]+/gi,
/\bauthorization:\s*bearer\s+[^'"\s]+/gi,
] as const;
const GENERIC_DELIVERY_DIAGNOSTIC_TOKENS = [
'opencode app mcp was reattached before message delivery',
'reattached stale opencode app mcp server',
'opencode session reconcile skipped because the stored session is stale',
'recreated opencode session before message delivery',
'opencode message delivery observe bridge failed',
'opencode bridge command timed out',
'opencode bootstrap mcp did not complete required tools before assistant response',
'existing app mcp config does not expose environment',
'empty_assistant_turn',
'visible_reply_still_required',
'prompt_delivered_no_assistant_message',
'plain_text_ack_only_still_requires_answer',
'visible_reply_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid',
'visible_reply_missing_task_refs',
'visible_reply_missing_task_refs_after_merge',
'visible_reply_task_refs_merge_failed',
'opencode_runtime_delivery_task_refs_inherited_from_relay',
'non_visible_tool_without_task_progress',
] as const;
const ACTION_REQUIRED_DELIVERY_ERROR_TOKENS = [
'auth_unavailable',
'no auth available',
'authentication_failed',
'unauthorized',
'forbidden',
'invalid api key',
'api key',
'does not have access',
'please run /login',
'insufficient credits',
'quota exceeded',
'quota exhausted',
'capacity exceeded',
'key limit exceeded',
'total limit',
] as const;
export function normalizeOpenCodeRuntimeDeliveryDiagnostic(
message: string | null | undefined
): string | null {
const scrubbed = SECRET_VALUE_PATTERNS.reduce(
(current, pattern) => current.replace(pattern, '[redacted]'),
message ?? ''
);
const normalized = scrubbed
?.replace(/\s+/g, ' ')
.trim()
.replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '')
.replace(/^APIError\s*[-:]\s*/i, '');
return normalized && normalized.length > 0 ? normalized : null;
return classifyRuntimeDiagnostic(message).normalizedMessage;
}
export function isGenericOpenCodeRuntimeDeliveryDiagnostic(message: string): boolean {
const normalized = message.trim().toLowerCase();
return GENERIC_DELIVERY_DIAGNOSTIC_TOKENS.some((token) => normalized.includes(token));
return classifyRuntimeDiagnostic(message).generic;
}
export function selectOpenCodeRuntimeDeliveryReason(
record: OpenCodePromptDeliveryLedgerRecord
): string | null {
const candidates = [...record.diagnostics.slice().reverse(), record.lastReason];
const normalized = candidates.flatMap((candidate) => {
const message = normalizeOpenCodeRuntimeDeliveryDiagnostic(candidate);
return message ? [message] : [];
});
const specific = normalized.find(
(message) => !isGenericOpenCodeRuntimeDeliveryDiagnostic(message)
);
if (specific) {
return boundOpenCodeRuntimeDeliveryReason(specific);
const selected = selectRuntimeDiagnosticClassification(candidates);
if (selected && !selected.generic && selected.normalizedMessage) {
return boundOpenCodeRuntimeDeliveryReason(selected.normalizedMessage);
}
const fallback = getOpenCodeRuntimeDeliveryStateFallback(record);
@ -87,17 +30,13 @@ export function selectOpenCodeRuntimeDeliveryReason(
return fallback;
}
return normalized.length > 0 ? 'OpenCode runtime delivery did not complete.' : null;
return selected ? 'OpenCode runtime delivery did not complete.' : null;
}
export function isActionRequiredOpenCodeRuntimeDeliveryReason(
message: string | null | undefined
): boolean {
const normalized = normalizeOpenCodeRuntimeDeliveryDiagnostic(message)?.toLowerCase();
if (!normalized) {
return false;
}
return ACTION_REQUIRED_DELIVERY_ERROR_TOKENS.some((token) => normalized.includes(token));
return classifyRuntimeDiagnostic(message).actionRequired;
}
function getOpenCodeRuntimeDeliveryStateFallback(

View file

@ -0,0 +1,213 @@
import type { MemberRuntimeAdvisory } from '@shared/types';
export interface RuntimeDiagnosticClassification {
reasonCode: NonNullable<MemberRuntimeAdvisory['reasonCode']>;
normalizedMessage: string | null;
priority: number;
actionRequired: boolean;
generic: boolean;
}
interface RuntimeDiagnosticRule {
reasonCode: RuntimeDiagnosticClassification['reasonCode'];
tokens: readonly string[];
priority: number;
actionRequired?: boolean;
generic?: boolean;
normalizeMessage?: (message: string) => string;
}
const SECRET_VALUE_PATTERNS = [
/\bsk-[A-Z0-9_-]{12,}\b/gi,
/\b[A-Z0-9_-]*api[_-]?key[A-Z0-9_-]*[=:]\s*['"]?[^'"\s]+/gi,
/\bauthorization:\s*bearer\s+[^'"\s]+/gi,
] as const;
const DISK_FULL_MESSAGE =
'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.';
const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
{
reasonCode: 'filesystem_error',
tokens: ['enospc', 'no space left on device', 'disk is full', 'local disk is full'],
priority: 100,
actionRequired: true,
normalizeMessage: () => DISK_FULL_MESSAGE,
},
{
reasonCode: 'quota_exhausted',
tokens: [
'exhausted your capacity',
'capacity exceeded',
'quota exceeded',
'quota exhausted',
'insufficient credits',
'key limit exceeded',
'total limit',
],
priority: 95,
actionRequired: true,
},
{
reasonCode: 'auth_error',
tokens: [
'auth_unavailable',
'no auth available',
'authentication_failed',
'unauthorized',
'forbidden',
'invalid api key',
'authentication',
'api key',
'does not have access',
'please run /login',
],
priority: 94,
actionRequired: true,
},
{
reasonCode: 'rate_limited',
tokens: ['rate limit', 'too many requests', '429', 'model cooldown', 'cooling down'],
priority: 85,
},
{
reasonCode: 'codex_native_timeout',
tokens: ['codex native exec timed out'],
priority: 80,
},
{
reasonCode: 'backend_error',
tokens: ['opencode bridge command timed out'],
priority: 20,
generic: true,
},
{
reasonCode: 'network_error',
tokens: ['timeout', 'timed out', 'network', 'connection', 'econn', 'enotfound', 'fetch failed'],
priority: 70,
},
{
reasonCode: 'provider_overloaded',
tokens: ['overloaded', 'temporarily unavailable', 'service unavailable', '503'],
priority: 65,
},
{
reasonCode: 'protocol_proof_missing',
tokens: [
'non_visible_tool_without_task_progress',
'visible_reply_still_required',
'visible_reply_ack_only_still_requires_answer',
'plain_text_ack_only_still_requires_answer',
'visible_reply_destination_not_found_yet',
'visible_reply_missing_relayofmessageid',
'visible_reply_missing_task_refs',
'visible_reply_missing_task_refs_after_merge',
'visible_reply_task_refs_merge_failed',
'did not create a visible reply',
'did not create a visible message_send reply',
'did not create a visible reply or task progress proof',
'without the required relayofmessageid correlation',
'without the required taskrefs metadata',
'could not be verified',
'no visible reply has been found yet',
],
priority: 60,
generic: true,
},
{
reasonCode: 'backend_error',
tokens: [
'empty_assistant_turn',
'empty assistant turn',
'prompt_delivered_no_assistant_message',
'accepted the prompt, but no assistant turn was recorded',
'opencode runtime delivery did not complete',
'opencode message delivery observe bridge failed',
'opencode bridge command timed out',
'opencode app mcp was reattached before message delivery',
'reattached stale opencode app mcp server',
'recreated opencode session before message delivery',
'opencode session reconcile skipped because the stored session is stale',
'opencode bootstrap mcp did not complete required tools before assistant response',
'existing app mcp config does not expose environment',
'messageabortederror',
'aborted',
'bridge stdout was empty',
],
priority: 20,
generic: true,
},
] as const;
const UNKNOWN_CLASSIFICATION: RuntimeDiagnosticClassification = {
reasonCode: 'unknown',
normalizedMessage: null,
priority: 0,
actionRequired: false,
generic: true,
};
export function normalizeRuntimeDiagnosticMessage(
message: string | null | undefined
): string | null {
const scrubbed = SECRET_VALUE_PATTERNS.reduce(
(current, pattern) => current.replace(pattern, '[redacted]'),
message ?? ''
);
const normalized = scrubbed
.replace(/\s+/g, ' ')
.trim()
.replace(
/^Latest assistant message(?:\s+\S+|\s+for\s+opencode\s+session\s+\S+)?\s+failed with\s+[^-:]+Error\s*[-:]\s*/i,
''
)
.replace(/^APIError\s*[-:]\s*/i, '');
return normalized.length > 0 ? normalized : null;
}
export function classifyRuntimeDiagnostic(
message: string | null | undefined
): RuntimeDiagnosticClassification {
const normalizedMessage = normalizeRuntimeDiagnosticMessage(message);
if (!normalizedMessage) {
return { ...UNKNOWN_CLASSIFICATION };
}
const normalized = normalizedMessage.toLowerCase();
const rule = RUNTIME_DIAGNOSTIC_RULES.find((candidate) =>
candidate.tokens.some((token) => normalized.includes(token))
);
if (!rule) {
return {
reasonCode: 'backend_error',
normalizedMessage,
priority: 50,
actionRequired: false,
generic: false,
};
}
return {
reasonCode: rule.reasonCode,
normalizedMessage: rule.normalizeMessage?.(normalizedMessage) ?? normalizedMessage,
priority: rule.priority,
actionRequired: rule.actionRequired === true,
generic: rule.generic === true,
};
}
export function selectRuntimeDiagnosticClassification(
messages: readonly (string | null | undefined)[]
): RuntimeDiagnosticClassification | null {
let selected: RuntimeDiagnosticClassification | null = null;
for (const message of messages) {
const classified = classifyRuntimeDiagnostic(message);
if (!classified.normalizedMessage) {
continue;
}
if (!selected || classified.priority > selected.priority) {
selected = classified;
}
}
return selected;
}

View file

@ -64,7 +64,10 @@ import {
parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
import {
isMemberWorkSyncNudgeMessage,
isTaskStallRemediationMessage,
} from '@shared/utils/teamAutomationMessages';
import {
AlertTriangle,
Check,
@ -403,7 +406,10 @@ const TaskStallRemediationRow = ({
return (
<div className="flex items-center gap-2 px-3 py-1.5" style={{ opacity: 0.82 }}>
<span className="bg-amber-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300">
<span
className="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
style={{ backgroundColor: 'rgba(245, 158, 11, 0.12)' }}
>
automation
</span>
<span className="text-[11px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
@ -442,6 +448,80 @@ const TaskStallRemediationRow = ({
);
};
const MemberWorkSyncNudgeRow = ({
teamName,
recipientName,
recipientColor,
taskRefs,
intent,
timestamp,
onMemberNameClick,
onTaskIdClick,
}: {
teamName: string;
recipientName: string;
recipientColor?: string;
taskRefs?: InboxMessage['taskRefs'];
intent?: InboxMessage['workSyncIntent'];
timestamp: string;
onMemberNameClick?: (memberName: string) => void;
onTaskIdClick?: (taskId: string) => void;
}): React.JSX.Element => {
const primaryTaskRef = taskRefs?.[0];
const taskLabel = primaryTaskRef
? formatTaskDisplayLabel({ id: primaryTaskRef.taskId, displayId: primaryTaskRef.displayId })
: null;
const extraTaskCount = Math.max((taskRefs?.length ?? 0) - 1, 0);
const body =
intent === 'review_pickup'
? 'Asked teammate to pick up review'
: 'Asked teammate to sync current work';
return (
<div className="flex items-center gap-2 px-3 py-1.5" style={{ opacity: 0.82 }}>
<span
className="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
style={{ backgroundColor: 'rgba(245, 158, 11, 0.12)' }}
>
automation
</span>
<span className="text-[11px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
work sync
</span>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
<MemberBadge
name={recipientName}
color={recipientColor}
teamName={teamName}
hideAvatar
onClick={onMemberNameClick}
/>
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_TEXT_LIGHT }}>
{body}
{primaryTaskRef && taskLabel ? (
<>
{' '}
<button
type="button"
className="font-medium text-blue-300 hover:text-blue-200"
onClick={(event) => {
event.stopPropagation();
onTaskIdClick?.(primaryTaskRef.taskId);
}}
>
{taskLabel}
</button>
{extraTaskCount > 0 ? ` +${extraTaskCount} more` : null}
</>
) : null}
</span>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp}
</span>
</div>
);
};
const BootstrapSystemRow = ({
teamName,
eventKind,
@ -1003,6 +1083,21 @@ export const ActivityItem = memo(
);
}
if (isMemberWorkSyncNudgeMessage(message)) {
return (
<MemberWorkSyncNudgeRow
teamName={teamName}
recipientName={message.to ?? 'teammate'}
recipientColor={recipientColor}
taskRefs={message.taskRefs}
intent={message.workSyncIntent}
timestamp={timestamp}
onMemberNameClick={onMemberNameClick}
onTaskIdClick={onTaskIdClick}
/>
);
}
if (bootstrapDisplay) {
return (
<BootstrapSystemRow

View file

@ -13,6 +13,48 @@ interface OptionalSettingsSectionProps {
children: React.ReactNode;
}
const SUMMARY_PREFIXES_TO_STRIP = ['Provider:', 'Model:', 'Effort:', 'Worktree:'];
const MODEL_LABEL_OVERRIDES: Array<[RegExp, string]> = [
[/claude[-\s]?opus[-\s]?4[-\s]?6/i, 'Opus 4.6'],
[/claude[-\s]?opus[-\s]?4[-\s]?7/i, 'Opus 4.7'],
[/claude[-\s]?opus[-\s]?4[-\s]?5/i, 'Opus 4.5'],
[/claude[-\s]?sonnet[-\s]?4[-\s]?6/i, 'Sonnet 4.6'],
[/claude[-\s]?sonnet[-\s]?4[-\s]?5/i, 'Sonnet 4.5'],
[/claude[-\s]?haiku[-\s]?4[-\s]?5/i, 'Haiku 4.5'],
];
const SUMMARY_CHIP_REWRITES: Array<[RegExp, string]> = [
[/^Auto-approve tools$/i, 'Tools auto'],
[/^Anthropic limited to 200K context$/i, '200K limit'],
];
const toCompactChip = (value: string): string => {
let chip = value.trim();
for (const prefix of SUMMARY_PREFIXES_TO_STRIP) {
if (chip.toLowerCase().startsWith(prefix.toLowerCase())) {
chip = chip.slice(prefix.length).trim();
break;
}
}
for (const [pattern, label] of MODEL_LABEL_OVERRIDES) {
if (pattern.test(chip)) {
chip = label;
break;
}
}
for (const [pattern, label] of SUMMARY_CHIP_REWRITES) {
if (pattern.test(chip)) {
chip = label;
break;
}
}
if (chip.length > 28) {
chip = `${chip.slice(0, 27)}`;
}
return chip;
};
export const OptionalSettingsSection = ({
title,
description,
@ -24,14 +66,25 @@ export const OptionalSettingsSection = ({
const [isOpen, setIsOpen] = useState(defaultOpen);
const { isLight } = useTheme();
const visibleSummary = useMemo(
() =>
summary
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 4),
[summary]
);
const chips = useMemo(() => {
const result: string[] = [];
const seen = new Set<string>();
for (const raw of summary) {
const chip = toCompactChip(raw);
if (!chip) continue;
const key = chip.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(chip);
if (result.length >= 3) break;
}
return result;
}, [summary]);
const overflowCount = useMemo(() => {
const total = summary.map((value) => value.trim()).filter(Boolean).length;
return Math.max(0, total - chips.length);
}, [summary, chips.length]);
const containerBackground = isLight
? 'color-mix(in srgb, var(--color-surface-overlay) 30%, white 70%)'
@ -65,46 +118,64 @@ export const OptionalSettingsSection = ({
>
<button
type="button"
className="flex w-full items-start justify-between gap-3 p-3 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
className="flex w-full items-center gap-3 p-2.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
onClick={() => setIsOpen((prev) => !prev)}
aria-expanded={isOpen}
>
<div className="flex min-w-0 items-start gap-2.5">
<div
className="mt-0.5 rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] p-1.5"
className="flex size-8 shrink-0 items-center justify-center rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)]"
style={{ color: headerIconColor }}
>
<Settings2 className="size-3.5" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium" style={{ color: headerTitleColor }}>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-sm font-medium" style={{ color: headerTitleColor }}>
{title}
</span>
<span
className="rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-2 py-0.5 text-[10px] uppercase tracking-wide"
className="shrink-0 rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium capitalize"
style={{ color: headerMutedColor }}
>
Optional
</span>
</div>
<p className="mt-1 text-xs" style={{ color: headerMutedColor }}>
{description}
</p>
{!isOpen ? (
<p className="mt-1.5 line-clamp-2 text-[11px]" style={{ color: headerMutedColor }}>
{visibleSummary.length > 0
? visibleSummary.join(' • ')
: 'Collapsed by default to keep the primary flow focused.'}
</p>
{!isOpen && chips.length > 0 ? (
<div
className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden"
style={{ color: headerMutedColor }}
>
<span aria-hidden="true" className="select-none text-[11px] opacity-50">
</span>
<div className="flex min-w-0 items-center gap-1.5">
{chips.map((chip, index) => (
<React.Fragment key={`${chip}-${index}`}>
{index > 0 ? (
<span aria-hidden="true" className="select-none text-[11px] opacity-50">
</span>
) : null}
<span className="truncate rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[11px]">
{chip}
</span>
</React.Fragment>
))}
{overflowCount > 0 ? (
<>
<span aria-hidden="true" className="select-none text-[11px] opacity-50">
</span>
<span className="shrink-0 text-[11px]">+{overflowCount}</span>
</>
) : null}
</div>
</div>
) : null}
</div>
<ChevronRight
className={cn(
'mt-0.5 size-4 shrink-0 transition-transform duration-150',
isOpen && 'rotate-90'
)}
className={cn('size-4 shrink-0 transition-transform duration-150', isOpen && 'rotate-90')}
style={{ color: headerIconColor }}
/>
</button>
@ -116,6 +187,11 @@ export const OptionalSettingsSection = ({
backgroundColor: contentBackground,
}}
>
{description ? (
<p className="mb-3 text-xs" style={{ color: headerMutedColor }}>
{description}
</p>
) : null}
{children}
</div>
) : null}

View file

@ -25,6 +25,7 @@ import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
import { isLeadMember } from '@shared/utils/leadDetection';
import {
isMemberWorkSyncNudgeMessage,
isReviewPickupEscalationMessage,
isTaskStallRemediationMessage,
} from '@shared/utils/teamAutomationMessages';
@ -606,6 +607,7 @@ export const MessagesPanel = memo(function MessagesPanel({
(m) =>
m.messageKind !== 'task_comment_notification' &&
!isTaskStallRemediationMessage(m) &&
!isMemberWorkSyncNudgeMessage(m) &&
!isReviewPickupEscalationMessage(m) &&
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
),

View file

@ -15,7 +15,10 @@ import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
@ -1866,6 +1869,8 @@ const resolvedMembersSelectorCache = new Map<
string,
{
snapshotRef: TeamViewSnapshot['members'];
configMembersRef: TeamViewSnapshot['config']['members'] | undefined;
tasksRef: TeamViewSnapshot['tasks'] | undefined;
metaMembersRef: TeamMemberActivityMeta['members'] | undefined;
result: ResolvedTeamMember[];
}
@ -1925,6 +1930,64 @@ function buildResolvedMembers(
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,
selectedFastMode: member.fastMode,
cwd: member.cwd,
removedAt: member.removedAt,
});
}
return fallbackMembers;
}
function getResolvableMemberSnapshots(snapshot: TeamViewSnapshot): readonly TeamMemberSnapshot[] {
return snapshot.members.length > 0
? snapshot.members
: buildConfigFallbackMemberSnapshots(snapshot);
}
function buildResolvedMember(
snapshot: TeamMemberSnapshot,
activity: MemberActivityMetaEntry | undefined
@ -2038,14 +2101,24 @@ export function selectResolvedMembersForTeamName(
const meta = state.memberActivityMetaByTeam[teamName];
const metaMembers = meta?.members;
const shouldUseConfigFallback = snapshot.members.length === 0;
const configMembersRef = shouldUseConfigFallback ? snapshot.config.members : undefined;
const tasksRef = shouldUseConfigFallback ? snapshot.tasks : undefined;
const cached = resolvedMembersSelectorCache.get(teamName);
if (cached?.snapshotRef === snapshot.members && cached.metaMembersRef === metaMembers) {
if (
cached?.snapshotRef === snapshot.members &&
cached.configMembersRef === configMembersRef &&
cached.tasksRef === tasksRef &&
cached.metaMembersRef === metaMembers
) {
return cached.result;
}
const result = buildResolvedMembers(snapshot.members, meta);
const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot), meta);
resolvedMembersSelectorCache.set(teamName, {
snapshotRef: snapshot.members,
configMembersRef,
tasksRef,
metaMembersRef: metaMembers,
result,
});
@ -2065,7 +2138,9 @@ export function selectResolvedMemberForTeamName(
return null;
}
const snapshotMember = snapshot.members.find((member) => member.name === memberName);
const snapshotMember = getResolvableMemberSnapshots(snapshot).find(
(member) => member.name === memberName
);
if (!snapshotMember) {
return null;
}
@ -3442,7 +3517,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
setTeamGraphLayoutMode: (teamName, mode) => {
set((state) => {
if ((state.graphLayoutModeByTeam[teamName] ?? 'radial') === mode) {
if ((state.graphLayoutModeByTeam[teamName] ?? DEFAULT_TEAM_GRAPH_LAYOUT_MODE) === mode) {
return {};
}

View file

@ -401,6 +401,8 @@ function formatRuntimeAdvisoryBaseLabel(
return 'Codex native timeout';
case 'network_error':
return 'Network error';
case 'filesystem_error':
return 'Disk space error';
case 'provider_overloaded':
return providerLabel ? `${providerLabel} overload` : 'Provider overload';
case 'protocol_proof_missing':
@ -430,6 +432,8 @@ function formatRuntimeAdvisoryBaseLabel(
return 'Codex native retry';
case 'network_error':
return 'Network retry';
case 'filesystem_error':
return 'Disk space retry';
case 'provider_overloaded':
return providerLabel ? `${providerLabel} overload retry` : 'Provider overload retry';
case 'protocol_proof_missing':
@ -471,6 +475,11 @@ function formatRuntimeAdvisoryTitle(
);
case 'network_error':
return appendRuntimeAdvisoryRawMessage('Network or connectivity error.', advisory.message);
case 'filesystem_error':
return appendRuntimeAdvisoryRawMessage(
'Local disk is full or unavailable.',
advisory.message
);
case 'provider_overloaded':
return appendRuntimeAdvisoryRawMessage(
'Provider is temporarily overloaded.',
@ -529,6 +538,11 @@ function formatRuntimeAdvisoryTitle(
'Network or connectivity issue. SDK is retrying automatically.',
advisory.message
);
case 'filesystem_error':
return appendRuntimeAdvisoryRawMessage(
'Local disk is full or unavailable. SDK is retrying automatically.',
advisory.message
);
case 'provider_overloaded':
return appendRuntimeAdvisoryRawMessage(
'Provider is temporarily overloaded. SDK is retrying automatically.',

View file

@ -5,6 +5,7 @@ import {
import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import {
isMemberWorkSyncNudgeMessage,
isReviewPickupEscalationMessage,
isTaskStallRemediationMessage,
} from '@shared/utils/teamAutomationMessages';
@ -135,7 +136,8 @@ export function filterTeamMessages(
let list = messages.filter(
(m) =>
m.messageKind !== 'task_comment_notification' &&
(includeAutomationEvents || !isTaskStallRemediationMessage(m)) &&
(includeAutomationEvents ||
(!isTaskStallRemediationMessage(m) && !isMemberWorkSyncNudgeMessage(m))) &&
!isReviewPickupEscalationMessage(m) &&
!isTeamInternalControlMessageEnvelope(m)
);

View file

@ -0,0 +1,3 @@
import type { GraphLayoutMode } from '@claude-teams/agent-graph';
export const DEFAULT_TEAM_GRAPH_LAYOUT_MODE: GraphLayoutMode = 'grid-under-lead';

View file

@ -857,6 +857,7 @@ export interface MemberRuntimeAdvisory {
| 'auth_error'
| 'codex_native_timeout'
| 'network_error'
| 'filesystem_error'
| 'provider_overloaded'
| 'protocol_proof_missing'
| 'backend_error'

View file

@ -18,6 +18,10 @@ export function isTaskStallRemediationMessage(message: AutomationMessageLike): b
);
}
export function isMemberWorkSyncNudgeMessage(message: AutomationMessageLike): boolean {
return message.messageKind === 'member_work_sync_nudge';
}
export function isReviewPickupEscalationMessage(message: AutomationMessageLike): boolean {
return (
message.source === 'system_notification' &&

View file

@ -52,6 +52,7 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
findLogsForTask: vi.fn(),
invalidateTeamConfig: vi.fn(),
invalidateTeamMessageFeed: vi.fn(),
invalidateMemberRuntimeAdvisory: vi.fn(),
},
}));
@ -218,9 +219,10 @@ describe('ipc teams handlers', () => {
getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => 'My Team'),
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
sendMessage: vi.fn(
async (_teamName: string, _request: unknown) => ({ deliveredToInbox: true, messageId: 'm1' })
) as ReturnType<
sendMessage: vi.fn(async (_teamName: string, _request: unknown) => ({
deliveredToInbox: true,
messageId: 'm1',
})) as ReturnType<
typeof vi.fn<
(
teamName: string,
@ -251,6 +253,7 @@ describe('ipc teams handlers', () => {
removeTaskRelationship: vi.fn(async () => undefined),
replaceMembers: vi.fn(async () => undefined),
invalidateMessageFeed: vi.fn(() => undefined),
invalidateTeamRuntimeAdvisories: vi.fn(() => undefined),
createTeamConfig: vi.fn(async () => undefined),
getSavedRequest: vi.fn(async (): Promise<TeamCreateRequest | null> => null),
};
@ -284,9 +287,7 @@ describe('ipc teams handlers', () => {
async (_teamName: string, _memberName: string): Promise<TeamProviderId | undefined> =>
undefined
) as ReturnType<
typeof vi.fn<
(teamName: string, memberName: string) => Promise<TeamProviderId | undefined>
>
typeof vi.fn<(teamName: string, memberName: string) => Promise<TeamProviderId | undefined>>
>,
isOpenCodeRuntimeRecipient: vi.fn(async () => false),
relayOpenCodeMemberInboxMessages: vi.fn(async () => ({
@ -369,6 +370,7 @@ describe('ipc teams handlers', () => {
mockTeamDataWorkerClient.findLogsForTask.mockReset();
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockReset();
provisioningService.resolveRuntimeRecipientProviderId.mockReset();
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined);
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockReset();
@ -700,11 +702,9 @@ describe('ipc teams handlers', () => {
expect(request?.text).not.toContain('Reply using the SendMessage tool');
});
it.each([
['anthropic' as const],
['gemini' as const],
[undefined],
])('keeps SendMessage reply instructions for %s user direct messages', async (providerId) => {
it.each([['anthropic' as const], ['gemini' as const], [undefined]])(
'keeps SendMessage reply instructions for %s user direct messages',
async (providerId) => {
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce(providerId);
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
@ -723,7 +723,8 @@ describe('ipc teams handlers', () => {
expect(request?.text).toContain('Reply using the SendMessage tool');
expect(request?.text).toContain('to="user"');
expect(request?.text).not.toContain('agent-teams_message_send');
});
}
);
it('stores base text and returns runtimeDelivery success for OpenCode teammate sends', async () => {
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
@ -1278,14 +1279,11 @@ describe('ipc teams handlers', () => {
.mockResolvedValueOnce([{ teamName: 'background-fresh', displayName: 'Background Fresh' }])
.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
const createResult = (await handlers.get(TEAM_CREATE)!(
{ sender: { send: vi.fn() } } as never,
{
const createResult = (await handlers.get(TEAM_CREATE)!({ sender: { send: vi.fn() } } as never, {
teamName: 'my-team',
members: [{ name: 'alice' }],
cwd: os.tmpdir(),
}
)) as { success: boolean };
})) as { success: boolean };
expect(createResult.success).toBe(false);
vi.mocked(console.error).mockClear();
@ -2752,6 +2750,78 @@ describe('ipc teams handlers', () => {
expect(service.permanentlyDeleteTeam).toHaveBeenCalledWith('my-team');
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
});
it('invalidates worker config cache after roster metadata mutations', async () => {
const addHandler = handlers.get(TEAM_ADD_MEMBER)!;
const removeHandler = handlers.get(TEAM_REMOVE_MEMBER)!;
const replaceHandler = handlers.get(TEAM_REPLACE_MEMBERS)!;
const updateRoleHandler = handlers.get(TEAM_UPDATE_MEMBER_ROLE)!;
let result = (await addHandler({} as never, 'my-team', {
name: 'alice',
role: 'developer',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(service.addMember).toHaveBeenCalledWith('my-team', {
name: 'alice',
role: 'developer',
});
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
'my-team'
);
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockClear();
result = (await removeHandler({} as never, 'my-team', 'alice')) as { success: boolean };
expect(result.success).toBe(true);
expect(service.removeMember).toHaveBeenCalledWith('my-team', 'alice');
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
'my-team'
);
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockClear();
result = (await replaceHandler({} as never, 'my-team', {
members: [{ name: 'bob', role: 'developer' }],
})) as { success: boolean };
expect(result.success).toBe(true);
expect(service.replaceMembers).toHaveBeenCalledWith('my-team', {
members: [
{
name: 'bob',
role: 'developer',
workflow: undefined,
isolation: undefined,
providerId: undefined,
providerBackendId: undefined,
model: undefined,
effort: undefined,
fastMode: undefined,
},
],
});
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
'my-team'
);
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockClear();
result = (await updateRoleHandler({} as never, 'my-team', 'bob', 'reviewer')) as {
success: boolean;
};
expect(result.success).toBe(true);
expect(service.updateMemberRole).toHaveBeenCalledWith('my-team', 'bob', 'reviewer');
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
'my-team'
);
});
});
describe('removeMember', () => {
@ -3671,7 +3741,10 @@ describe('ipc teams handlers', () => {
try {
const teamDir = path.join(claudeRoot, 'teams', 'anthropic-team');
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(path.join(teamDir, 'config.json'), JSON.stringify({ teamName: 'anthropic-team' }));
fs.writeFileSync(
path.join(teamDir, 'config.json'),
JSON.stringify({ teamName: 'anthropic-team' })
);
fs.writeFileSync(
path.join(teamDir, 'team.meta.json'),
JSON.stringify({

View file

@ -94,6 +94,30 @@ describe('OpenCodeRuntimeDeliveryAdvisoryPolicy', () => {
});
});
it('surfaces disk-full delivery failures immediately', () => {
const record = makeRecord({
responseState: 'empty_assistant_turn',
lastReason: 'empty_assistant_turn',
diagnostics: [
"OpenCode message bridge failed: ENOSPC: no space left on device, open '/tmp/.auth.json.tmp'",
'Latest assistant message msg_1 failed with MessageAbortedError - Aborted',
'empty_assistant_turn',
],
});
expect(
decideOpenCodeRuntimeDeliveryAdvisory({
record,
now: Date.parse(record.failedAt!) + 1_000,
})
).toMatchObject({
action: 'surface',
severity: 'error',
reasonCode: 'filesystem_error',
reason: 'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.',
});
});
it('suppresses generic retryable tool errors before terminal failure', () => {
const record = makeRecord({
status: 'failed_retryable',

View file

@ -32,6 +32,25 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Key limit exceeded');
});
it('prioritizes local disk-full diagnostics over secondary aborted assistant errors', () => {
const record = {
diagnostics: [
"OpenCode message bridge failed: ENOSPC: no space left on device, open '/tmp/.auth.json.tmp'",
"ENOSPC: no space left on device, open '/tmp/.auth.json.tmp'",
'OpenCode app MCP was reattached before message delivery.',
'Latest assistant message msg_1 failed with MessageAbortedError - Aborted',
'empty_assistant_turn',
],
lastReason: 'empty_assistant_turn',
responseState: 'empty_assistant_turn',
status: 'failed_terminal',
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.'
);
});
it('formats non-visible tool progress failures without exposing the internal reason code', () => {
const record = {
diagnostics: ['non_visible_tool_without_task_progress'],

View file

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import {
classifyRuntimeDiagnostic,
selectRuntimeDiagnosticClassification,
} from '../../../../src/main/services/team/runtime/RuntimeDiagnosticClassifier';
describe('RuntimeDiagnosticClassifier', () => {
it('selects disk-full errors over aborted and empty OpenCode noise', () => {
const selected = selectRuntimeDiagnosticClassification([
'Latest assistant message msg_1 failed with MessageAbortedError - Aborted',
'empty_assistant_turn',
"OpenCode message bridge failed: ENOSPC: no space left on device, open '/tmp/.auth.json.tmp'",
]);
expect(selected).toMatchObject({
reasonCode: 'filesystem_error',
normalizedMessage: 'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.',
actionRequired: true,
generic: false,
});
});
it('selects quota errors over empty assistant turns', () => {
const selected = selectRuntimeDiagnosticClassification([
'empty_assistant_turn',
'Latest assistant message msg_2 failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits',
]);
expect(selected).toMatchObject({
reasonCode: 'quota_exhausted',
normalizedMessage:
'Insufficient credits. Add more using https://openrouter.ai/settings/credits',
actionRequired: true,
});
});
it('selects auth errors over bridge timeouts', () => {
const selected = selectRuntimeDiagnosticClassification([
'OpenCode bridge command timed out',
'authentication_failed: invalid API key',
]);
expect(selected).toMatchObject({
reasonCode: 'auth_error',
normalizedMessage: 'authentication_failed: invalid API key',
actionRequired: true,
});
});
it('keeps pure empty assistant turns as generic backend fallback', () => {
expect(classifyRuntimeDiagnostic('empty_assistant_turn')).toMatchObject({
reasonCode: 'backend_error',
normalizedMessage: 'empty_assistant_turn',
generic: true,
actionRequired: false,
});
});
it('keeps protocol proof failures above generic runtime noise', () => {
const selected = selectRuntimeDiagnosticClassification([
'OpenCode bridge command timed out',
'visible_reply_missing_task_refs',
]);
expect(selected).toMatchObject({
reasonCode: 'protocol_proof_missing',
normalizedMessage: 'visible_reply_missing_task_refs',
generic: true,
actionRequired: false,
});
});
});

View file

@ -169,6 +169,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
],
['codex_native_timeout', 'Codex native exec timed out after 120000ms.'],
['network_error', 'Fetch failed because the network connection timed out.'],
['filesystem_error', 'ENOSPC: no space left on device, write'],
['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'],
['protocol_proof_missing', 'OpenCode created a reply without the required taskRefs metadata.'],
['backend_error', 'Unexpected backend blew up during request processing.'],

View file

@ -578,4 +578,47 @@ describe('ActivityItem legacy system message fallback', () => {
await Promise.resolve();
});
});
it('renders member work sync nudges as a compact automation row', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const message: InboxMessage = {
from: 'system',
to: 'tom',
text: [
'Work sync check: you have current actionable work assigned.',
'Required sync action: call member_work_sync_status with teamName "launchpad".',
'Then call member_work_sync_report with reportToken.',
].join('\n'),
summary: 'Work sync check',
timestamp: new Date('2026-04-13T13:36:00.000Z').toISOString(),
read: true,
source: 'system_notification',
messageKind: 'member_work_sync_nudge',
workSyncIntent: 'agenda_sync',
messageId: 'member-work-sync:launchpad:tom:agenda-a',
taskRefs: [{ taskId: 'task-a', displayId: '#b63b9065', teamName: 'launchpad' }],
};
await act(async () => {
root.render(React.createElement(ActivityItem, { message, teamName: 'launchpad' }));
await Promise.resolve();
});
expect(host.textContent).toContain('automation');
expect(host.textContent).toContain('work sync');
expect(host.textContent).toContain('tom');
expect(host.textContent).toContain('#b63b9065');
expect(host.textContent).not.toContain('member_work_sync_status');
expect(host.textContent).not.toContain('member_work_sync_report');
expect(host.textContent).not.toContain('reportToken');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -4,8 +4,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { useStore } from '@renderer/store';
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: { children: React.ReactNode }) =>
React.createElement('span', null, children),
Badge: ({
children,
className,
style,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) => React.createElement('span', { className, style }, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
@ -132,6 +139,47 @@ describe('GraphNodePopover spawn badge labels', () => {
});
expect(host.textContent).toContain('spawn failed');
expect(
Array.from(host.querySelectorAll('span')).some(
(badge) =>
badge.textContent === 'spawn failed' && badge.className.includes('text-red-300')
)
).toBe(true);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('renders launch exception status text in red when it is the primary status', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(GraphNodePopover, {
node: {
...makeMemberNode('online'),
launchStatusLabel: 'OpenCode API error',
exceptionTone: 'error',
exceptionLabel: 'OpenCode API error',
},
teamName: 'northstar-core',
onClose: vi.fn(),
})
);
await Promise.resolve();
});
expect(
Array.from(host.querySelectorAll('span')).some(
(badge) =>
badge.textContent === 'OpenCode API error' && badge.className.includes('text-red-300')
)
).toBe(true);
await act(async () => {
root.unmount();

View file

@ -172,9 +172,16 @@ describe('TeamGraphAdapter particles', () => {
undefined,
undefined,
undefined,
'grid-under-lead'
'radial'
);
expect(graph.layout?.mode).toBe('radial');
});
it('defaults the graph layout mode to rows', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(createBaseTeamData(), 'my-team');
expect(graph.layout?.mode).toBe('grid-under-lead');
});
@ -487,7 +494,8 @@ describe('TeamGraphAdapter particles', () => {
undefined,
{
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
}
},
'radial'
);
expect(graph.layout?.ownerOrder).toEqual([
@ -552,7 +560,8 @@ describe('TeamGraphAdapter particles', () => {
undefined,
{
'agent-alice': { ringIndex: 1, sectorIndex: 4 },
}
},
'radial'
);
expect(graph.layout?.ownerOrder).toEqual([

View file

@ -23,11 +23,21 @@ interface FillTextCall {
globalAlpha: number;
}
interface GradientStopCall {
offset: number;
color: string;
}
function createMockContext() {
const fillTextCalls: FillTextCall[] = [];
const strokeTextCalls: FillTextCall[] = [];
const roundRectCalls: Array<{ x: number; y: number; width: number; height: number }> = [];
const gradient = { addColorStop: vi.fn() };
const roundRectCalls: { x: number; y: number; width: number; height: number }[] = [];
const gradientStops: GradientStopCall[] = [];
const gradient = {
addColorStop: vi.fn((offset: number, color: string) => {
gradientStops.push({ offset, color });
}),
};
let fillStyle = '';
let globalAlpha = 1;
@ -84,7 +94,7 @@ function createMockContext() {
},
} as unknown as CanvasRenderingContext2D;
return { ctx, fillTextCalls, strokeTextCalls, roundRectCalls };
return { ctx, fillTextCalls, strokeTextCalls, roundRectCalls, gradientStops };
}
describe('drawAgents', () => {
@ -215,4 +225,25 @@ describe('drawAgents', () => {
2
);
});
it('adds a red glow around members with error exceptions', () => {
const { ctx, gradientStops } = createMockContext();
const node: GraphNode = {
id: 'member:demo:bob',
kind: 'member',
label: 'bob',
state: 'active',
color: '#7c3aed',
exceptionTone: 'error',
exceptionLabel: 'OpenCode API error',
domainRef: { kind: 'member', teamName: 'demo', memberName: 'bob' },
x: 320,
y: 240,
};
drawAgents(ctx, [node], 0, null, null, null, 1);
expect(ctx.createRadialGradient).toHaveBeenCalledWith(320, 240, 18, 320, 240, 50);
expect(gradientStops.some((stop) => stop.color.startsWith('#ef4444'))).toBe(true);
});
});

View file

@ -321,7 +321,7 @@ describe('stable slot layout planner', () => {
if (!snapshot) {
throw new Error('Expected stable slot layout snapshot');
}
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
expect(validateStableSlotLayout(snapshot)).toEqual({ valid: true });
for (const frame of snapshot.memberSlotFrames) {
for (const centralRect of snapshot.centralCollisionRects) {
@ -334,8 +334,8 @@ describe('stable slot layout planner', () => {
}
}
for (const [index, left] of snapshot!.memberSlotFrames.entries()) {
for (const right of snapshot!.memberSlotFrames.slice(index + 1)) {
for (const [index, left] of snapshot.memberSlotFrames.entries()) {
for (const right of snapshot.memberSlotFrames.slice(index + 1)) {
if (!rectsOverlapVertically(left.bounds, right.bounds)) {
continue;
}
@ -971,6 +971,46 @@ describe('stable slot layout planner', () => {
expect(frames[0].processBandRect.height).toBe(STABLE_SLOT_GEOMETRY.processBandHeight);
});
it('keeps six grid-under-lead members in two-column rows', () => {
const teamName = 'team-grid-six';
const lead = createLead(teamName);
const members = [
createMember(teamName, 'agent-alice', 'alice'),
createMember(teamName, 'agent-bob', 'bob'),
createMember(teamName, 'agent-tom', 'tom'),
createMember(teamName, 'agent-jack', 'jack'),
createMember(teamName, 'agent-eve', 'eve'),
createMember(teamName, 'agent-sam', 'sam'),
];
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
mode: 'grid-under-lead',
ownerOrder: members.map((member) => member.id),
slotAssignments: {},
};
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, ...members],
layout,
});
expect(snapshot).not.toBeNull();
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
const frames = snapshot!.memberSlotFrames;
expect(frames).toHaveLength(6);
expect(frames[0].ownerY).toBe(frames[1].ownerY);
expect(frames[2].ownerY).toBe(frames[3].ownerY);
expect(frames[4].ownerY).toBe(frames[5].ownerY);
expect(frames[2].ownerY).toBeGreaterThan(frames[0].ownerY);
expect(frames[4].ownerY).toBeGreaterThan(frames[2].ownerY);
expect(frames[0].ownerX).toBeLessThan(0);
expect(frames[1].ownerX).toBeGreaterThan(0);
expect(frames[4].ownerX).toBeLessThan(0);
expect(frames[5].ownerX).toBeGreaterThan(0);
});
it('keeps wide grid-under-lead rows from overlapping horizontally', () => {
const teamName = 'team-grid-wide';
const lead = createLead(teamName);

View file

@ -768,22 +768,22 @@ describe('teamSlice actions', () => {
});
});
it('stores graph layout mode without mutating radial slot assignments', () => {
it('stores non-default graph layout mode without mutating radial slot assignments', () => {
const store = createSliceStore();
store
.getState()
.commitTeamGraphOwnerSlotDrop('my-team', 'agent-alice', { ringIndex: 0, sectorIndex: 2 });
store.getState().setTeamGraphLayoutMode('my-team', 'grid-under-lead');
store.getState().setTeamGraphLayoutMode('my-team', 'radial');
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('grid-under-lead');
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('radial');
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
});
store.getState().setTeamGraphLayoutMode('my-team', 'radial');
store.getState().setTeamGraphLayoutMode('my-team', 'grid-under-lead');
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('radial');
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('grid-under-lead');
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
});
@ -2486,6 +2486,51 @@ describe('teamSlice actions', () => {
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1);
});
it('falls back to config roster when snapshot members are temporarily empty', () => {
const store = createSliceStore();
const partialSnapshot = createTeamSnapshot({
config: {
name: 'My Team',
members: [
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
{ name: 'alice', role: 'reviewer', providerId: 'anthropic', color: 'blue' },
{ name: 'bob', role: 'developer', providerId: 'opencode' },
],
},
members: [],
tasks: [
{
id: 'task-1',
subject: 'Review current diff',
status: 'in_progress',
owner: 'alice',
},
],
});
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: partialSnapshot,
teamDataCacheByName: {
'my-team': partialSnapshot,
},
memberActivityMetaByTeam: {},
});
const members = selectResolvedMembersForTeamName(store.getState(), 'my-team');
expect(members.map((member) => member.name)).toEqual(['team-lead', 'alice', 'bob']);
expect(members.find((member) => member.name === 'alice')).toMatchObject({
role: 'reviewer',
currentTaskId: 'task-1',
taskCount: 1,
});
expect(selectResolvedMemberForTeamName(store.getState(), 'my-team', 'bob')).toMatchObject({
name: 'bob',
role: 'developer',
});
});
it('memoizes team-scoped member messages selectors over the merged message feed', () => {
const store = createSliceStore();
store.setState({

View file

@ -763,6 +763,20 @@ describe('memberHelpers spawn-aware presence', () => {
).toContain('Connection timed out while contacting provider.');
});
it('renders local filesystem advisories as disk space errors', () => {
const advisory = {
kind: 'api_error' as const,
observedAt: '2026-04-07T09:00:00.000Z',
reasonCode: 'filesystem_error' as const,
message: 'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.',
};
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('Disk space error');
expect(getMemberRuntimeAdvisoryTitle(advisory, 'opencode')).toContain(
'Local disk is full or unavailable.'
);
});
it('renders terminal API errors as errors instead of retrying status', () => {
expect(
getMemberRuntimeAdvisoryLabel(

View file

@ -585,6 +585,32 @@ Messages:
expect(result.map((message) => message.messageId)).toEqual(['msg-2']);
});
it('hides member work sync nudges from conversational message counts by default', () => {
const messages = [
makeMessage({
messageId: 'member-work-sync:demo:jack:agenda-a',
from: 'system',
to: 'jack',
source: 'system_notification',
messageKind: 'member_work_sync_nudge',
summary: 'Work sync check',
text: 'Work sync check: call member_work_sync_status.',
}),
makeMessage({
messageId: 'msg-2',
text: 'Visible message',
}),
];
const result = filterTeamMessages(messages, {
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '',
});
expect(result.map((message) => message.messageId)).toEqual(['msg-2']);
});
it('hides review pickup escalation automation rows from conversational message counts by default', () => {
const messages = [
makeMessage({
@ -634,6 +660,31 @@ Messages:
]);
});
it('can include member work sync nudges for the activity timeline', () => {
const messages = [
makeMessage({
messageId: 'member-work-sync:demo:jack:agenda-a',
from: 'system',
to: 'jack',
source: 'system_notification',
messageKind: 'member_work_sync_nudge',
summary: 'Work sync check',
text: 'Work sync check: call member_work_sync_status.',
}),
];
const result = filterTeamMessages(messages, {
includeAutomationEvents: true,
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '',
});
expect(result.map((message) => message.messageId)).toEqual([
'member-work-sync:demo:jack:agenda-a',
]);
});
it('keeps review pickup escalation hidden even when regular automation rows are included', () => {
const messages = [
makeMessage({