Merge pull request #210 from 777genius/dev

dev -> main
This commit is contained in:
Илия 2026-06-06 23:50:13 +03:00 committed by GitHub
commit 4fdb39bd37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
158 changed files with 12391 additions and 3947 deletions

View file

@ -3,10 +3,10 @@ name: Dependency Review
on:
pull_request:
paths:
- "**/package.json"
- "**/package-lock.json"
- "**/pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- '**/package.json'
- '**/package-lock.json'
- '**/pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
permissions:
contents: read
@ -24,5 +24,7 @@ jobs:
with:
fail-on-severity: high
fail-on-scopes: runtime, development, unknown
# Vitest is used via `vitest run`, not Vitest UI/API/browser mode.
allow-ghsas: GHSA-5xrq-8626-4rwp
license-check: false
show-patched-versions: true

View file

@ -0,0 +1,46 @@
# Agent Teams with Claude Opus 4.8
Claude Opus 4.8 made a single agent noticeably stronger. But the real productivity jump comes when this model powers **a whole team of agents** that talk to each other, coordinate, and autonomously carry a task through to the result.
Here's what that looks like in practice.
## A team of agents instead of just one
You assemble a team of several Opus 4.8 agents and assign roles: lead, backend, frontend, reviewer — whatever fits your task. From there they work in parallel, each in their own area.
If you're not ready for a full team yet, there's a solo mode with a single agent that manages its own task list. You can grow it into a full team later.
## They talk and coordinate
This isn't a bunch of independent chats. The agents:
- message each other inside the team
- hand off results and ask each other for clarification
- code-review each other's work
- create and close tasks on a shared kanban board on their own
And here's something you usually don't see anywhere else: **you can run multiple teams at once, and they coordinate between themselves**. Spin up parallel teams for different tracks (say, backend and frontend, or two features at once) — their leads will talk to each other, sync progress, and pass results down the chain.
You set the goal at a high level — breakdown, distribution, and execution happen without you.
## Everything is visible in the UI
In real time:
- **Kanban** with tasks moving across statuses
- **Diff viewer** for every task: accept / reject / comment
- **Agent-to-agent chat** plus direct messages with any of them
- **Detailed logs** for every agent — what it did, which commands it ran, which decisions it made
- **Per-task view**: open a card on the kanban and see everything tied to it — code changes, agent conversations, comments, logs. No confusion about what belongs where
- **Active agent sessions** with open links
- **Notifications** when the team is done or needs your input
## Not just Claude
Beyond Opus 4.8, you can plug **Codex** and **OpenCode** agents into the same team _(200+ models, 70+ LLM providers)_. Different runtimes coexist within a single team — pick the strengths of each where they fit best, without locking yourself into one vendor.
## Under the hood
Local, no cloud, free, open source. It works through the Claude / Codex / OpenCode CLIs you already have installed — no separate app-level API keys required.
---
> Screenshots and video below.

View file

@ -0,0 +1,46 @@
# Agent Teams with Claude Opus 4.8
С выходом Claude Opus 4.8 одиночный агент стал заметно сильнее. Но настоящий скачок продуктивности — когда на этой модели работает **целая команда агентов**, которые общаются между собой, координируются и автономно доводят задачу до результата.
Ниже — как это выглядит на практике.
## Команда агентов вместо одного
Вы собираете команду из нескольких Opus 4.8-агентов и распределяете роли: лид, бэкенд, фронтенд, ревьюер — что угодно под вашу задачу. Дальше они работают параллельно, каждый в своей зоне.
Если не хочется сразу команду — есть solo-режим с одним агентом, который сам ведёт свои задачи. Дальше его можно развернуть в полноценную команду.
## Они общаются и координируются
Это не несколько независимых чатов. Агенты:
- пишут друг другу внутри команды
- передают результаты, спрашивают уточнения
- делают код-ревью работы коллег
- сами создают и закрывают задачи на общем канбане
И ещё одна штука, которой обычно нет нигде: **команд может быть несколько, и они координируются между собой**. Можно поднять параллельно несколько команд под разные направления (например, бэкенд и фронтенд, или две фичи сразу) — и их лиды будут общаться друг с другом, синхронизировать работу, передавать результаты по цепочке.
Вы ставите цель на высоком уровне — декомпозиция, распределение и исполнение происходят без вашего участия.
## Всё видно в UI
В реальном времени:
- **Канбан** с движением задач по статусам
- **Diff-вьювер** на каждую задачу: accept / reject / комментарий
- **Переписка** агентов между собой и личные DM с любым из них
- **Подробные логи** каждого агента — что он делал, какие команды запускал, какие решения принимал
- **Срез по задаче**: открываете карточку на канбане и видите всё, что к ней относится — изменения в коде, переписку агентов, комментарии, логи. Никакой путаницы, что к чему привязано
- **Активные сессии агентов** с открытыми ссылками
- **Уведомления**, когда команда закончила или ей нужно ваше решение
## Не только Claude
Помимо Opus 4.8, в команду можно подключать агентов на **Codex** и **OpenCode** _(200+ моделей, 70+ LLM-провайдеров)_. В одной команде спокойно уживаются разные рантаймы — берёте сильные стороны каждого там, где они уместны, без привязки к одному вендору.
## Под капотом
Локально, без облака, бесплатно, open source. Работает через уже установленные у вас CLI Claude / Codex / OpenCode — отдельные API-ключи на уровне приложения не нужны.
---
> Скриншоты и видео — ниже.

View file

@ -1827,9 +1827,9 @@ OpenCode lead rule:
- Mixed team with native Codex/Claude/Gemini lead: keep the existing `relayLeadInboxMessages()` path.
- OpenCode teammate or secondary lane: use `relayOpenCodeMemberInboxMessages()`.
- Pure OpenCode lead inbox in v1: do not mark messages read and do not report delivery success unless a real stored OpenCode `team-lead` session exists. Return a diagnostic like `opencode_lead_runtime_session_missing`.
- Pure OpenCode lead inbox: launch and store a real OpenCode `team-lead` runtime session, then relay through `relayOpenCodeMemberInboxMessages()`. Do not mark messages read unless that delivery is accepted.
- Do not fake lead delivery by sending to a random teammate session. That would make messages appear delivered while the actual recipient never saw them.
- A future explicit OpenCode lead lane can reuse this selector by teaching the bridge to create/store a `team-lead` session and by passing `agent: "team-lead"` where the bridge supports it. That is not part of this v1 seam.
- If the stored `team-lead` session is missing, keep the row retryable instead of falling back to another teammate.
FileWatcher change:
@ -2967,13 +2967,12 @@ it('routes native lead inbox relay through the legacy stdin path', async () => {
```
```ts
it('does not silently consume pure OpenCode lead inbox when no lead session exists', async () => {
// Configure a pure OpenCode runtime-adapter team where isTeamAlive() is true via runtimeAdapterRunByTeam.
// Ensure there is no stored OpenCode session record for the canonical lead name.
it('relays pure OpenCode lead inbox through the stored lead session', async () => {
// Configure a pure OpenCode runtime-adapter team with a stored team-lead session.
// Seed inboxes/<lead>.json with one unread message.
// Call relayInboxFileToLiveRecipient(teamName, leadName).
// Assert diagnostics include opencode_lead_runtime_session_missing.
// Assert the inbox row remains unread and no teammate session received the prompt.
// Assert the relay kind is opencode_member and the prompt targets team-lead.
// Assert the inbox row is marked read only after accepted runtime delivery.
});
```
@ -3464,8 +3463,8 @@ Avoid heavy E2E until targeted tests pass.
- UI direct sends to live OpenCode teammates either confirm runtime delivery or show a visible warning; there is no log-only post-send delivery failure.
- Persisted inbox messages addressed to OpenCode teammates are live-relayed to their runtime lanes, while native teammates keep file-watch behavior and lead keeps lead relay behavior.
- OpenCode inbox relay is direct-to-runtime and does not reuse native `relayMemberInboxMessages()` / `SendMessage` forwarding.
- Pure OpenCode lead inbox delivery is not silently consumed: without a real OpenCode lead session, rows remain unread and diagnostics say `opencode_lead_runtime_session_missing` or equivalent.
- Pure OpenCode lead inbox delivery uses the stored `team-lead` runtime session and does not silently fall back to another teammate.
- Renderer send-message actions return `SendMessageResult` on success and reject on real send failure, so pending-reply cleanup is not dependent on dead `.catch()` paths.
- `message_send` cannot create `from: "user", to: "user"` rows; user-directed MCP replies require a configured teammate sender.
- OpenCode replies appear in Messages UI without frontend fake state.
- Tests cover native default, OpenCode override, assignment protocol, tool alias canonicalization, tool proof, taskRefs persistence, user-directed sender guard, local recipient canonicalization, direct-message runtime delivery result visibility, OpenCode reply feed projection, OpenCode-targeted inbox relay/dedupe, unsupported OpenCode lead diagnostics, launch identity injection, lane-scoped manifest activeRunId recovery, and runtime delivery team-change event shape.
- Tests cover native default, OpenCode override, assignment protocol, tool alias canonicalization, tool proof, taskRefs persistence, user-directed sender guard, local recipient canonicalization, direct-message runtime delivery result visibility, OpenCode reply feed projection, OpenCode-targeted inbox relay/dedupe, pure OpenCode lead relay, launch identity injection, lane-scoped manifest activeRunId recovery, and runtime delivery team-change event shape.

View file

@ -20,10 +20,10 @@ const props = defineProps<{
reducedMotion?: boolean;
}>();
const { locale } = useI18n();
const { t, locale } = useI18n();
const localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value));
const localizedHeroReviewerFeatureCard = computed(() => getLocalizedHeroReviewerFeatureCard(locale.value));
const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:");
const statusLabel = computed(() => t("common.statusLabel"));
const icons = [
mdiRobotOutline,

View file

@ -7,12 +7,12 @@ const props = defineProps<{
activeReceiver?: HeroAgentRole | "video" | null;
}>();
const { locale } = useI18n();
const { t } = useI18n();
const isSender = computed(() => props.activeSender === props.agent.id);
const isReceiver = computed(() => props.activeReceiver === props.agent.id);
const imageLoading = computed(() => (props.agent.priority ? "eager" : "lazy"));
const imageFetchPriority = computed(() => (props.agent.priority ? "high" : "auto"));
const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:");
const statusLabel = computed(() => t("common.statusLabel"));
const rootStyle = computed(() => ({
"--agent-x": String(props.agent.desktop.x),

View file

@ -1,6 +1,5 @@
<script setup lang="ts">
const { locale } = useI18n();
const isRu = computed(() => locale.value === "ru");
const { t } = useI18n();
</script>
<template>
@ -8,12 +7,12 @@ const isRu = computed(() => locale.value === "ru");
id="hero-demo"
class="cyber-video-frame"
role="region"
:aria-label="isRu ? 'Смотреть демо Agent Teams' : 'Watch Agent Teams demo'"
:aria-label="t('hero.videoFrameLabel')"
>
<div class="cyber-video-frame__bezel" aria-hidden="true" />
<div class="cyber-video-frame__status" aria-hidden="true">
<span>{{ isRu ? 'Командная лента' : 'Team command feed' }}</span>
<span>{{ isRu ? 'Живое демо' : 'Live demo' }}</span>
<span>{{ t('hero.commandFeed') }}</span>
<span>{{ t('hero.liveDemo') }}</span>
</div>
<div class="cyber-video-frame__content">
<HeroDemoVideo />

View file

@ -251,7 +251,7 @@ const releaseDate = computed(() => {
day: 'numeric',
});
});
const linuxRobotBubble = computed(() => locale.value === 'ru' ? 'Готов начать!' : 'Ready to start!');
const linuxRobotBubble = computed(() => t('download.readyToStart'));
</script>

View file

@ -55,14 +55,10 @@ const supportedProviders = [
},
] as const;
const supportedProvidersLabel = computed(() => (
locale.value === "ru"
? "Поддерживаем AI-провайдеры"
: "Supported AI providers"
t("hero.supportedProviders")
));
const heroSlogan = computed(() => (
locale.value === "ru"
? "Делайте много, почти ничего не делая"
: "Get a lot done by doing very little"
t("hero.slogan")
));
const heroDownloadUrl = computed(() => {
@ -79,17 +75,13 @@ const docsHref = computed(() => buildDocsHref({
}));
const downloadActionSubtitle = computed(() => {
if (!selectedDownloadAsset.value) {
return locale.value === "ru"
? "Для вашей платформы"
: "For your platform";
return t("hero.platformDefault");
}
return selectedDownloadAsset.value.actionSubtitle;
});
const docsActionSubtitle = computed(() => (
locale.value === "ru"
? "Гайды и настройка"
: "Guides and setup"
t("hero.guidesSetup")
));
function clearHeroMessageTimers() {

View file

@ -27,8 +27,8 @@ const screenshots = computed(() => screenshotData.map((s) => ({
width: s.width,
height: s.height,
})));
const prevLabel = computed(() => locale.value === 'ru' ? 'Предыдущий' : 'Previous');
const nextLabel = computed(() => locale.value === 'ru' ? 'Следующий' : 'Next');
const prevLabel = computed(() => t('common.previous'));
const nextLabel = computed(() => t('common.next'));
const swiperRef = ref<SwiperContainerElement | null>(null);
const swiperReady = ref(false);

View file

@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
import { mdiRobotOutline, mdiCheckCircleOutline, mdiCodeBraces, mdiMessageTextOutline } from '@mdi/js';
const { t } = useI18n();
// State machine for demo cycle
type DemoState = 'idle' | 'working' | 'reviewing' | 'done';
const state = ref<DemoState>('idle');
@ -9,13 +11,13 @@ const state = ref<DemoState>('idle');
// Animated task text
const currentTask = ref('');
const taskFading = ref(false);
const TASKS = [
'Implementing auth middleware...',
'Writing unit tests for API...',
'Reviewing PR #42 changes...',
'Setting up CI/CD pipeline...',
'Refactoring database layer...',
];
const taskMessages = computed(() => [
t('hero.demo.activity.authMiddleware'),
t('hero.demo.activity.unitTests'),
t('hero.demo.activity.reviewPr'),
t('hero.demo.activity.ciPipeline'),
t('hero.demo.activity.refactorDatabase'),
]);
let taskIndex = 0;
let charTimer: ReturnType<typeof setTimeout> | null = null;
@ -28,9 +30,9 @@ const agents = ref([
// Kanban mini-board
const kanbanTasks = ref([
{ id: 1, text: 'Auth API', col: 'todo' as string },
{ id: 2, text: 'Unit tests', col: 'todo' as string },
{ id: 3, text: 'CI setup', col: 'todo' as string },
{ id: 1, text: t('hero.demo.tasks.authApi'), col: 'todo' as string },
{ id: 2, text: t('hero.demo.tasks.unitTests'), col: 'todo' as string },
{ id: 3, text: t('hero.demo.tasks.ciSetup'), col: 'todo' as string },
]);
function typeNextChar(text: string, index: number) {
@ -76,9 +78,9 @@ function runCycle() {
currentTask.value = '';
taskFading.value = false;
kanbanTasks.value = [
{ id: 1, text: 'Auth API', col: 'todo' },
{ id: 2, text: 'Unit tests', col: 'todo' },
{ id: 3, text: 'CI setup', col: 'todo' },
{ id: 1, text: t('hero.demo.tasks.authApi'), col: 'todo' },
{ id: 2, text: t('hero.demo.tasks.unitTests'), col: 'todo' },
{ id: 3, text: t('hero.demo.tasks.ciSetup'), col: 'todo' },
];
agents.value.forEach(a => a.status = 'idle');
@ -91,7 +93,8 @@ function runCycle() {
agents.value[1].status = 'active';
kanbanTasks.value[0].col = 'progress';
const task = TASKS[taskIndex % TASKS.length];
const messages = taskMessages.value;
const task = messages[taskIndex % messages.length];
taskIndex++;
typeNextChar(task, 0);
@ -179,6 +182,16 @@ function colColor(col: string) {
}
}
function colLabel(col: string) {
switch (col) {
case 'todo': return t('hero.demo.columns.todo');
case 'progress': return t('hero.demo.columns.progress');
case 'review': return t('hero.demo.columns.review');
case 'done': return t('hero.demo.columns.done');
default: return col.toUpperCase();
}
}
function statusDotColor(status: string) {
switch (status) {
case 'active': return '#00f0ff';
@ -190,7 +203,7 @@ function statusDotColor(status: string) {
</script>
<template>
<div ref="containerRef" class="hero-demo" role="img" aria-label="Agent team demo">
<div ref="containerRef" class="hero-demo" role="img" :aria-label="t('hero.demo.ariaLabel')">
<div class="hero-demo__content">
<!-- Header -->
<div class="hero-demo__header">
@ -198,7 +211,7 @@ function statusDotColor(status: string) {
<span class="hero-demo__title">Agent Teams</span>
<span class="hero-demo__badge-live">
<span class="hero-demo__live-dot" />
LIVE
{{ t('hero.demo.live') }}
</span>
</div>
</div>
@ -225,7 +238,7 @@ function statusDotColor(status: string) {
<div class="hero-demo__kanban">
<div v-for="col in ['todo', 'progress', 'review', 'done']" :key="col" class="hero-demo__kanban-col">
<div class="hero-demo__kanban-label" :style="{ color: colColor(col) }">
{{ col === 'progress' ? 'IN PROGRESS' : col.toUpperCase() }}
{{ colLabel(col) }}
</div>
<div class="hero-demo__kanban-cards">
<TransitionGroup name="kanban-card">
@ -249,7 +262,7 @@ function statusDotColor(status: string) {
<span
class="hero-demo__log-text"
:class="{ 'hero-demo__log-text--fading': taskFading }"
>{{ currentTask || 'Waiting for tasks...' }}</span>
>{{ currentTask || t('hero.demo.waiting') }}</span>
</div>
</div>
</div>

View file

@ -2,19 +2,15 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
import { mdiPlay } from "@mdi/js";
const { t, locale } = useI18n();
const { t } = useI18n();
const config = useRuntimeConfig();
const muxAccentColor = "#00f0ff";
const muxPrimaryColor = "#e6fbff";
const muxSecondaryColor = "#020617";
const muxPlaybackId = computed(() => String(config.public.muxPlaybackId || "").trim());
const videoTitle = computed(() => (
locale.value === "ru" ? "Демо-видео Agent Teams" : "Agent Teams demo video"
));
const muxVideoTitle = computed(() => (
locale.value === "ru" ? "Демо Agent Teams" : "Agent Teams demo"
));
const videoTitle = computed(() => t("hero.demoVideoTitle"));
const muxVideoTitle = computed(() => t("hero.demoTitle"));
const muxPlayerUrl = computed(() => {
if (!muxPlaybackId.value) return "";

View file

@ -7,7 +7,16 @@
"download": "تحميل",
"pricing": "مجاني",
"faq": "الأسئلة الشائعة",
"viewOnGithub": "View on GitHub"
"viewOnGithub": "View on GitHub",
"openMenu": "فتح القائمة",
"closeMenu": "إغلاق القائمة",
"short": {
"screenshots": "صور",
"docs": "Docs",
"download": "تحميل",
"comparison": "مقارنة",
"pricing": "مجاني"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "مفتوح المصدر"
},
"watchDemo": "شاهد العرض",
"videoUnavailable": "الفيديو غير متوفر"
"videoUnavailable": "الفيديو غير متوفر",
"supportedProviders": "مزودو AI المدعومون",
"slogan": "أنجز الكثير بجهد قليل جدًا",
"platformDefault": "لمنصتك",
"guidesSetup": "الأدلة والإعداد",
"videoFrameLabel": "شاهد عرض Agent Teams",
"commandFeed": "تدفق أوامر الفريق",
"liveDemo": "عرض مباشر",
"demoVideoTitle": "فيديو عرض Agent Teams",
"demoTitle": "عرض Agent Teams",
"demo": {
"ariaLabel": "عرض فريق الوكلاء",
"live": "LIVE",
"waiting": "بانتظار المهام...",
"activity": {
"authMiddleware": "تنفيذ middleware للمصادقة...",
"unitTests": "كتابة اختبارات وحدة للـ API...",
"reviewPr": "مراجعة تغييرات PR #42...",
"ciPipeline": "إعداد pipeline CI/CD...",
"refactorDatabase": "إعادة هيكلة طبقة قاعدة البيانات..."
},
"tasks": {
"authApi": "Auth API",
"unitTests": "اختبارات وحدة",
"ciSetup": "إعداد CI"
},
"columns": {
"todo": "TODO",
"progress": "قيد التنفيذ",
"review": "مراجعة",
"done": "تم"
}
}
},
"download": {
"title": "تحميل",
"detected": "تم الكشف",
"systemRequirements": "متطلبات النظام",
"version": "الإصدار {version}"
"version": "الإصدار {version}",
"readyToStart": "جاهز للبدء!"
},
"theme": {
"dark": "داكن",
@ -100,7 +142,10 @@
"sectionSubtitle": "لقطات شاشة حقيقية من التطبيق — لوحة كانبان، مراجعة الكود، فرق الوكلاء، والمزيد."
},
"common": {
"learnMore": "اعرف المزيد"
"learnMore": "اعرف المزيد",
"statusLabel": "الحالة:",
"previous": "السابق",
"next": "التالي"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "أنا أنتظر",
"links": {
"github": "GitHub",
"author": "المؤلف",
"docs": "التوثيق"
}
},

View file

@ -7,7 +7,16 @@
"download": "ডাউনলোড করা হয়েছে",
"pricing": "মুক্ত",
"faq": "FAQ",
"viewOnGithub": "প্রদর্শন GitHub"
"viewOnGithub": "প্রদর্শন GitHub",
"openMenu": "মেনু খুলুন",
"closeMenu": "মেনু বন্ধ করুন",
"short": {
"screenshots": "স্ক্রিন",
"docs": "Docs",
"download": "ডাউনলোড",
"comparison": "তুলনা",
"pricing": "ফ্রি"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "উৎস খুলুন"
},
"watchDemo": "নমুনা",
"videoUnavailable": "ভিডিও উপলব্ধ নয়"
"videoUnavailable": "ভিডিও উপলব্ধ নয়",
"supportedProviders": "সমর্থিত AI providers",
"slogan": "খুব কম কাজ করে অনেক কিছু করুন",
"platformDefault": "আপনার platform-এর জন্য",
"guidesSetup": "Guides এবং setup",
"videoFrameLabel": "Agent Teams demo দেখুন",
"commandFeed": "Team command feed",
"liveDemo": "Live demo",
"demoVideoTitle": "Agent Teams demo video",
"demoTitle": "Agent Teams demo",
"demo": {
"ariaLabel": "Agent team demo",
"live": "LIVE",
"waiting": "Tasks এর অপেক্ষা...",
"activity": {
"authMiddleware": "Auth middleware implement হচ্ছে...",
"unitTests": "API এর জন্য unit tests লেখা হচ্ছে...",
"reviewPr": "PR #42 changes review হচ্ছে...",
"ciPipeline": "CI/CD pipeline setup হচ্ছে...",
"refactorDatabase": "Database layer refactor হচ্ছে..."
},
"tasks": {
"authApi": "Auth API",
"unitTests": "Unit tests",
"ciSetup": "CI setup"
},
"columns": {
"todo": "TODO",
"progress": "IN PROGRESS",
"review": "REVIEW",
"done": "DONE"
}
}
},
"download": {
"title": "ডাউনলোড করা হয়েছে",
"detected": "সনাক্ত",
"systemRequirements": "সিস্টেম প্রয়োজন",
"version": "সংস্করণ {version}"
"version": "সংস্করণ {version}",
"readyToStart": "শুরু করার জন্য প্রস্তুত!"
},
"theme": {
"dark": "কালো",
@ -100,7 +142,10 @@
"sectionSubtitle": "অ্যাপটির বাস্তব স্ক্রিনশট —কানবান বোর্ড, কোড পর্যালোচনা, এজেন্ট দল এবং আরও অনেক কিছু ।"
},
"common": {
"learnMore": "অারো জানুন"
"learnMore": "অারো জানুন",
"statusLabel": "স্ট্যাটাস:",
"previous": "আগের",
"next": "পরের"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "আমি অপেক্ষা করছি",
"links": {
"github": "GitHub",
"author": "লেখক",
"docs": "নথিপত্র"
}
},

View file

@ -7,7 +7,16 @@
"download": "Download",
"pricing": "Kostenlos",
"faq": "FAQ",
"viewOnGithub": "View on GitHub"
"viewOnGithub": "View on GitHub",
"openMenu": "Menü öffnen",
"closeMenu": "Menü schließen",
"short": {
"screenshots": "Bilder",
"docs": "Docs",
"download": "Laden",
"comparison": "Vergleich",
"pricing": "Gratis"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "Open Source"
},
"watchDemo": "Demo ansehen",
"videoUnavailable": "Video nicht verfügbar"
"videoUnavailable": "Video nicht verfügbar",
"supportedProviders": "Unterstützte KI-Anbieter",
"slogan": "Viel erledigen mit sehr wenig Aufwand",
"platformDefault": "Für Ihre Plattform",
"guidesSetup": "Anleitungen und Einrichtung",
"videoFrameLabel": "Agent Teams Demo ansehen",
"commandFeed": "Team-Befehlsfeed",
"liveDemo": "Live-Demo",
"demoVideoTitle": "Agent Teams Demo-Video",
"demoTitle": "Agent Teams Demo",
"demo": {
"ariaLabel": "Agententeam-Demo",
"live": "LIVE",
"waiting": "Warte auf Aufgaben...",
"activity": {
"authMiddleware": "Auth-Middleware wird implementiert...",
"unitTests": "Unit-Tests für API werden geschrieben...",
"reviewPr": "PR #42 Änderungen werden geprüft...",
"ciPipeline": "CI/CD-Pipeline wird eingerichtet...",
"refactorDatabase": "Datenbankschicht wird refaktoriert..."
},
"tasks": {
"authApi": "Auth API",
"unitTests": "Unit-Tests",
"ciSetup": "CI Setup"
},
"columns": {
"todo": "TODO",
"progress": "IN ARBEIT",
"review": "REVIEW",
"done": "FERTIG"
}
}
},
"download": {
"title": "Herunterladen",
"detected": "Erkannt",
"systemRequirements": "Systemanforderungen",
"version": "Version {version}"
"version": "Version {version}",
"readyToStart": "Bereit zum Start!"
},
"theme": {
"dark": "Dunkel",
@ -100,7 +142,10 @@
"sectionSubtitle": "Echte Screenshots der App — Kanban-Board, Code-Review, Agenten-Teams und mehr."
},
"common": {
"learnMore": "Mehr erfahren"
"learnMore": "Mehr erfahren",
"statusLabel": "Status:",
"previous": "Zurück",
"next": "Weiter"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "Ich warte",
"links": {
"github": "GitHub",
"author": "Autor",
"docs": "Dokumentation"
}
},

View file

@ -7,7 +7,16 @@
"download": "Download",
"pricing": "Free",
"faq": "FAQ",
"viewOnGithub": "View on GitHub"
"viewOnGithub": "View on GitHub",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"short": {
"screenshots": "Shots",
"docs": "Docs",
"download": "Get",
"comparison": "Compare",
"pricing": "Free"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "Open Source"
},
"watchDemo": "Watch Demo",
"videoUnavailable": "Video unavailable"
"videoUnavailable": "Video unavailable",
"supportedProviders": "Supported AI providers",
"slogan": "Get a lot done by doing very little",
"platformDefault": "For your platform",
"guidesSetup": "Guides and setup",
"videoFrameLabel": "Watch Agent Teams demo",
"commandFeed": "Team command feed",
"liveDemo": "Live demo",
"demoVideoTitle": "Agent Teams demo video",
"demoTitle": "Agent Teams demo",
"demo": {
"ariaLabel": "Agent team demo",
"live": "LIVE",
"waiting": "Waiting for tasks...",
"activity": {
"authMiddleware": "Implementing auth middleware...",
"unitTests": "Writing unit tests for API...",
"reviewPr": "Reviewing PR #42 changes...",
"ciPipeline": "Setting up CI/CD pipeline...",
"refactorDatabase": "Refactoring database layer..."
},
"tasks": {
"authApi": "Auth API",
"unitTests": "Unit tests",
"ciSetup": "CI setup"
},
"columns": {
"todo": "TODO",
"progress": "IN PROGRESS",
"review": "REVIEW",
"done": "DONE"
}
}
},
"download": {
"title": "Download",
"detected": "Detected",
"systemRequirements": "System requirements",
"version": "Version {version}"
"version": "Version {version}",
"readyToStart": "Ready to start!"
},
"theme": {
"dark": "Dark",
@ -100,7 +142,10 @@
"sectionSubtitle": "Real screenshots from the app — kanban board, code review, agent teams, and more."
},
"common": {
"learnMore": "Learn more"
"learnMore": "Learn more",
"statusLabel": "Status:",
"previous": "Previous",
"next": "Next"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "I'm waiting",
"links": {
"github": "GitHub",
"author": "Author",
"docs": "Documentation"
}
},

View file

@ -7,7 +7,16 @@
"download": "Descargar",
"pricing": "Gratis",
"faq": "FAQ",
"viewOnGithub": "View on GitHub"
"viewOnGithub": "View on GitHub",
"openMenu": "Abrir menú",
"closeMenu": "Cerrar menú",
"short": {
"screenshots": "Capturas",
"docs": "Docs",
"download": "Bajar",
"comparison": "Comparar",
"pricing": "Gratis"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "Open Source"
},
"watchDemo": "Ver demo",
"videoUnavailable": "Vídeo no disponible"
"videoUnavailable": "Vídeo no disponible",
"supportedProviders": "Proveedores de IA compatibles",
"slogan": "Haz mucho haciendo muy poco",
"platformDefault": "Para tu plataforma",
"guidesSetup": "Guías y configuración",
"videoFrameLabel": "Ver demo de Agent Teams",
"commandFeed": "Feed de comandos del equipo",
"liveDemo": "Demo en vivo",
"demoVideoTitle": "Vídeo demo de Agent Teams",
"demoTitle": "Demo de Agent Teams",
"demo": {
"ariaLabel": "Demo del equipo de agentes",
"live": "LIVE",
"waiting": "Esperando tareas...",
"activity": {
"authMiddleware": "Implementando middleware de autenticación...",
"unitTests": "Escribiendo pruebas unitarias para API...",
"reviewPr": "Revisando cambios del PR #42...",
"ciPipeline": "Configurando pipeline CI/CD...",
"refactorDatabase": "Refactorizando capa de base de datos..."
},
"tasks": {
"authApi": "API de auth",
"unitTests": "Pruebas unitarias",
"ciSetup": "Config CI"
},
"columns": {
"todo": "TODO",
"progress": "EN PROGRESO",
"review": "REVISIÓN",
"done": "LISTO"
}
}
},
"download": {
"title": "Descargar",
"detected": "Detectado",
"systemRequirements": "Requisitos del sistema",
"version": "Versión {version}"
"version": "Versión {version}",
"readyToStart": "Listo para empezar!"
},
"theme": {
"dark": "Oscuro",
@ -100,7 +142,10 @@
"sectionSubtitle": "Capturas reales de la aplicación — tablero kanban, revisión de código, equipos de agentes y más."
},
"common": {
"learnMore": "Más información"
"learnMore": "Más información",
"statusLabel": "Estado:",
"previous": "Anterior",
"next": "Siguiente"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "Estoy esperando",
"links": {
"github": "GitHub",
"author": "Autor",
"docs": "Documentación"
}
},

View file

@ -7,7 +7,16 @@
"download": "Télécharger",
"pricing": "Gratuit",
"faq": "FAQ",
"viewOnGithub": "View on GitHub"
"viewOnGithub": "View on GitHub",
"openMenu": "Ouvrir le menu",
"closeMenu": "Fermer le menu",
"short": {
"screenshots": "Captures",
"docs": "Docs",
"download": "Télécharger",
"comparison": "Comparer",
"pricing": "Gratuit"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "Open Source"
},
"watchDemo": "Voir la démo",
"videoUnavailable": "Vidéo indisponible"
"videoUnavailable": "Vidéo indisponible",
"supportedProviders": "Fournisseurs IA compatibles",
"slogan": "Faites beaucoup en faisant très peu",
"platformDefault": "Pour votre plateforme",
"guidesSetup": "Guides et configuration",
"videoFrameLabel": "Regarder la démo Agent Teams",
"commandFeed": "Flux de commandes d'équipe",
"liveDemo": "Démo en direct",
"demoVideoTitle": "Vidéo démo Agent Teams",
"demoTitle": "Démo Agent Teams",
"demo": {
"ariaLabel": "Démo d'équipe d'agents",
"live": "LIVE",
"waiting": "En attente de tâches...",
"activity": {
"authMiddleware": "Implémentation du middleware d'auth...",
"unitTests": "Écriture de tests unitaires pour l'API...",
"reviewPr": "Revue des changements PR #42...",
"ciPipeline": "Configuration du pipeline CI/CD...",
"refactorDatabase": "Refactorisation de la couche base de données..."
},
"tasks": {
"authApi": "API auth",
"unitTests": "Tests unitaires",
"ciSetup": "Config CI"
},
"columns": {
"todo": "TODO",
"progress": "EN COURS",
"review": "REVUE",
"done": "TERMINÉ"
}
}
},
"download": {
"title": "Télécharger",
"detected": "Détecté",
"systemRequirements": "Configuration requise",
"version": "Version {version}"
"version": "Version {version}",
"readyToStart": "Prêt à commencer!"
},
"theme": {
"dark": "Sombre",
@ -100,7 +142,10 @@
"sectionSubtitle": "Captures d'écran réelles — tableau kanban, revue de code, équipes d'agents et plus."
},
"common": {
"learnMore": "En savoir plus"
"learnMore": "En savoir plus",
"statusLabel": "Statut:",
"previous": "Précédent",
"next": "Suivant"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "J'attends",
"links": {
"github": "GitHub",
"author": "Auteur",
"docs": "Documentation"
}
},

View file

@ -7,7 +7,16 @@
"download": "डाउनलोड",
"pricing": "मुफ़्त",
"faq": "FAQ",
"viewOnGithub": "View on GitHub"
"viewOnGithub": "View on GitHub",
"openMenu": "मेनू खोलें",
"closeMenu": "मेनू बंद करें",
"short": {
"screenshots": "शॉट्स",
"docs": "Docs",
"download": "डाउनलोड",
"comparison": "तुलना",
"pricing": "मुफ्त"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "ओपन सोर्स"
},
"watchDemo": "डेमो देखें",
"videoUnavailable": "वीडियो उपलब्ध नहीं"
"videoUnavailable": "वीडियो उपलब्ध नहीं",
"supportedProviders": "समर्थित AI providers",
"slogan": "बहुत कम करके बहुत कुछ करें",
"platformDefault": "आपके platform के लिए",
"guidesSetup": "Guides और setup",
"videoFrameLabel": "Agent Teams demo देखें",
"commandFeed": "Team command feed",
"liveDemo": "Live demo",
"demoVideoTitle": "Agent Teams demo video",
"demoTitle": "Agent Teams demo",
"demo": {
"ariaLabel": "Agent team demo",
"live": "LIVE",
"waiting": "Tasks का इंतज़ार...",
"activity": {
"authMiddleware": "Auth middleware implement हो रहा है...",
"unitTests": "API के लिए unit tests लिखे जा रहे हैं...",
"reviewPr": "PR #42 changes review हो रहे हैं...",
"ciPipeline": "CI/CD pipeline setup हो रही है...",
"refactorDatabase": "Database layer refactor हो रही है..."
},
"tasks": {
"authApi": "Auth API",
"unitTests": "Unit tests",
"ciSetup": "CI setup"
},
"columns": {
"todo": "TODO",
"progress": "IN PROGRESS",
"review": "REVIEW",
"done": "DONE"
}
}
},
"download": {
"title": "डाउनलोड",
"detected": "पहचाना गया",
"systemRequirements": "सिस्टम आवश्यकताएँ",
"version": "संस्करण {version}"
"version": "संस्करण {version}",
"readyToStart": "शुरू करने के लिए तैयार!"
},
"theme": {
"dark": "डार्क",
@ -100,7 +142,10 @@
"sectionSubtitle": "ऐप के असली स्क्रीनशॉट — कानबन बोर्ड, कोड रिव्यू, एजेंट टीमें, और बहुत कुछ।"
},
"common": {
"learnMore": "और जानें"
"learnMore": "और जानें",
"statusLabel": "स्थिति:",
"previous": "पिछला",
"next": "अगला"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "मैं इंतज़ार कर रहा हूँ",
"links": {
"github": "GitHub",
"author": "लेखक",
"docs": "दस्तावेज़"
}
},

View file

@ -7,7 +7,16 @@
"download": "Unduh",
"pricing": "Bebas",
"faq": "FAQ",
"viewOnGithub": "Tilik pada GitHub"
"viewOnGithub": "Tilik pada GitHub",
"openMenu": "Buka menu",
"closeMenu": "Tutup menu",
"short": {
"screenshots": "Gambar",
"docs": "Docs",
"download": "Unduh",
"comparison": "Banding",
"pricing": "Gratis"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "Buka Sumber"
},
"watchDemo": "Watch Demo",
"videoUnavailable": "Video tidak tersedia"
"videoUnavailable": "Video tidak tersedia",
"supportedProviders": "Penyedia AI yang didukung",
"slogan": "Selesaikan banyak hal dengan sangat sedikit aksi",
"platformDefault": "Untuk platform Anda",
"guidesSetup": "Panduan dan penyiapan",
"videoFrameLabel": "Tonton demo Agent Teams",
"commandFeed": "Feed perintah tim",
"liveDemo": "Demo langsung",
"demoVideoTitle": "Video demo Agent Teams",
"demoTitle": "Demo Agent Teams",
"demo": {
"ariaLabel": "Demo tim agen",
"live": "LIVE",
"waiting": "Menunggu tugas...",
"activity": {
"authMiddleware": "Menerapkan middleware auth...",
"unitTests": "Menulis unit test untuk API...",
"reviewPr": "Meninjau perubahan PR #42...",
"ciPipeline": "Menyiapkan pipeline CI/CD...",
"refactorDatabase": "Merefactor layer database..."
},
"tasks": {
"authApi": "Auth API",
"unitTests": "Unit test",
"ciSetup": "Setup CI"
},
"columns": {
"todo": "TODO",
"progress": "BERJALAN",
"review": "REVIEW",
"done": "SELESAI"
}
}
},
"download": {
"title": "Unduh",
"detected": "Terdeteksi",
"systemRequirements": "Kebutuhan sistem",
"version": "Versi {version}"
"version": "Versi {version}",
"readyToStart": "Siap mulai!"
},
"theme": {
"dark": "Gelap",
@ -100,7 +142,10 @@
"sectionSubtitle": "Gambar layar nyata dari aplikasi - papan kanban, ulasan kode, tim agen, dan banyak lagi."
},
"common": {
"learnMore": "Pelajari lagi"
"learnMore": "Pelajari lagi",
"statusLabel": "Status:",
"previous": "Sebelumnya",
"next": "Berikutnya"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "Aku menunggu",
"links": {
"github": "GitHub",
"author": "Penulis",
"docs": "Dokumentasi"
}
},

View file

@ -7,7 +7,16 @@
"download": "ダウンロード",
"pricing": "無料",
"faq": "FAQ",
"viewOnGithub": "View on GitHub"
"viewOnGithub": "View on GitHub",
"openMenu": "メニューを開く",
"closeMenu": "メニューを閉じる",
"short": {
"screenshots": "画像",
"docs": "Docs",
"download": "入手",
"comparison": "比較",
"pricing": "無料"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "オープンソース"
},
"watchDemo": "デモを見る",
"videoUnavailable": "動画は利用できません"
"videoUnavailable": "動画は利用できません",
"supportedProviders": "対応 AI プロバイダー",
"slogan": "少ない操作で多くをこなす",
"platformDefault": "お使いのプラットフォーム向け",
"guidesSetup": "ガイドとセットアップ",
"videoFrameLabel": "Agent Teams のデモを見る",
"commandFeed": "チームコマンドフィード",
"liveDemo": "ライブデモ",
"demoVideoTitle": "Agent Teams デモ動画",
"demoTitle": "Agent Teams デモ",
"demo": {
"ariaLabel": "エージェントチームのデモ",
"live": "LIVE",
"waiting": "タスクを待機中...",
"activity": {
"authMiddleware": "認証ミドルウェアを実装中...",
"unitTests": "API のユニットテストを作成中...",
"reviewPr": "PR #42 の変更をレビュー中...",
"ciPipeline": "CI/CD パイプラインを設定中...",
"refactorDatabase": "データベース層をリファクタリング中..."
},
"tasks": {
"authApi": "認証 API",
"unitTests": "ユニットテスト",
"ciSetup": "CI 設定"
},
"columns": {
"todo": "TODO",
"progress": "進行中",
"review": "レビュー",
"done": "完了"
}
}
},
"download": {
"title": "ダウンロード",
"detected": "検出済み",
"systemRequirements": "動作環境",
"version": "バージョン {version}"
"version": "バージョン {version}",
"readyToStart": "開始できます!"
},
"theme": {
"dark": "ダーク",
@ -100,7 +142,10 @@
"sectionSubtitle": "アプリの実際のスクリーンショット — カンバンボード、コードレビュー、エージェントチームなど。"
},
"common": {
"learnMore": "詳細"
"learnMore": "詳細",
"statusLabel": "ステータス:",
"previous": "前へ",
"next": "次へ"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "待ってるよ",
"links": {
"github": "GitHub",
"author": "作者",
"docs": "ドキュメント"
}
},

View file

@ -7,7 +7,16 @@
"download": "다운로드",
"pricing": "무료",
"faq": "FAQ",
"viewOnGithub": "GitHub에서 보기"
"viewOnGithub": "GitHub에서 보기",
"openMenu": "메뉴 열기",
"closeMenu": "메뉴 닫기",
"short": {
"screenshots": "샷",
"docs": "문서",
"download": "받기",
"comparison": "비교",
"pricing": "무료"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "오픈 소스"
},
"watchDemo": "데모 보기",
"videoUnavailable": "동영상을 사용할 수 없습니다"
"videoUnavailable": "동영상을 사용할 수 없습니다",
"supportedProviders": "지원되는 AI 제공자",
"slogan": "아주 적은 조작으로 많은 일을 처리하세요",
"platformDefault": "현재 플랫폼용",
"guidesSetup": "가이드 및 설정",
"videoFrameLabel": "Agent Teams 데모 보기",
"commandFeed": "팀 명령 피드",
"liveDemo": "라이브 데모",
"demoVideoTitle": "Agent Teams 데모 동영상",
"demoTitle": "Agent Teams 데모",
"demo": {
"ariaLabel": "에이전트 팀 데모",
"live": "LIVE",
"waiting": "작업 대기 중...",
"activity": {
"authMiddleware": "인증 미들웨어 구현 중...",
"unitTests": "API 단위 테스트 작성 중...",
"reviewPr": "PR #42 변경 사항 검토 중...",
"ciPipeline": "CI/CD 파이프라인 설정 중...",
"refactorDatabase": "데이터베이스 계층 리팩터링 중..."
},
"tasks": {
"authApi": "Auth API",
"unitTests": "단위 테스트",
"ciSetup": "CI 설정"
},
"columns": {
"todo": "TODO",
"progress": "진행 중",
"review": "리뷰",
"done": "완료"
}
}
},
"download": {
"title": "다운로드",
"detected": "감지됨",
"systemRequirements": "시스템 요구 사항",
"version": "버전 {version}"
"version": "버전 {version}",
"readyToStart": "시작할 준비 완료!"
},
"theme": {
"dark": "다크",
@ -100,7 +142,10 @@
"sectionSubtitle": "칸반 보드, 코드 리뷰, 에이전트 팀 등 앱의 실제 스크린샷입니다."
},
"common": {
"learnMore": "자세히 알아보기"
"learnMore": "자세히 알아보기",
"statusLabel": "상태:",
"previous": "이전",
"next": "다음"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "기다리고 있어요",
"links": {
"github": "GitHub",
"author": "작성자",
"docs": "문서"
}
},

View file

@ -7,7 +7,16 @@
"download": "Baixar",
"pricing": "Grátis",
"faq": "FAQ",
"viewOnGithub": "View on GitHub"
"viewOnGithub": "View on GitHub",
"openMenu": "Abrir menu",
"closeMenu": "Fechar menu",
"short": {
"screenshots": "Imagens",
"docs": "Docs",
"download": "Baixar",
"comparison": "Comparar",
"pricing": "Grátis"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "Open Source"
},
"watchDemo": "Ver demo",
"videoUnavailable": "Vídeo indisponível"
"videoUnavailable": "Vídeo indisponível",
"supportedProviders": "Provedores de IA compatíveis",
"slogan": "Faça muito fazendo muito pouco",
"platformDefault": "Para sua plataforma",
"guidesSetup": "Guias e configuração",
"videoFrameLabel": "Assistir demo do Agent Teams",
"commandFeed": "Feed de comandos da equipe",
"liveDemo": "Demo ao vivo",
"demoVideoTitle": "Vídeo demo do Agent Teams",
"demoTitle": "Demo do Agent Teams",
"demo": {
"ariaLabel": "Demo da equipe de agentes",
"live": "LIVE",
"waiting": "Aguardando tarefas...",
"activity": {
"authMiddleware": "Implementando middleware de autenticação...",
"unitTests": "Escrevendo testes unitários para API...",
"reviewPr": "Revisando mudanças do PR #42...",
"ciPipeline": "Configurando pipeline CI/CD...",
"refactorDatabase": "Refatorando camada de banco de dados..."
},
"tasks": {
"authApi": "API auth",
"unitTests": "Testes unitários",
"ciSetup": "Setup CI"
},
"columns": {
"todo": "TODO",
"progress": "EM PROGRESSO",
"review": "REVISÃO",
"done": "PRONTO"
}
}
},
"download": {
"title": "Baixar",
"detected": "Detectado",
"systemRequirements": "Requisitos do sistema",
"version": "Versão {version}"
"version": "Versão {version}",
"readyToStart": "Pronto para começar!"
},
"theme": {
"dark": "Escuro",
@ -100,7 +142,10 @@
"sectionSubtitle": "Capturas reais do app — quadro kanban, revisão de código, equipes de agentes e mais."
},
"common": {
"learnMore": "Saiba mais"
"learnMore": "Saiba mais",
"statusLabel": "Status:",
"previous": "Anterior",
"next": "Próximo"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "Estou esperando",
"links": {
"github": "GitHub",
"author": "Autor",
"docs": "Documentação"
}
},

View file

@ -7,7 +7,16 @@
"download": "Скачать",
"pricing": "Бесплатно",
"faq": "FAQ",
"viewOnGithub": "GitHub"
"viewOnGithub": "GitHub",
"openMenu": "Открыть меню",
"closeMenu": "Закрыть меню",
"short": {
"screenshots": "Скрины",
"docs": "Док",
"download": "Скачать",
"comparison": "Сравн.",
"pricing": "Беспл."
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "Open Source"
},
"watchDemo": "Смотреть демо",
"videoUnavailable": "Видео недоступно"
"videoUnavailable": "Видео недоступно",
"supportedProviders": "Поддерживаем AI-провайдеры",
"slogan": "Делайте много, почти ничего не делая",
"platformDefault": "Для вашей платформы",
"guidesSetup": "Гайды и настройка",
"videoFrameLabel": "Смотреть демо Agent Teams",
"commandFeed": "Командная лента",
"liveDemo": "Живое демо",
"demoVideoTitle": "Демо-видео Agent Teams",
"demoTitle": "Демо Agent Teams",
"demo": {
"ariaLabel": "Демо команды агентов",
"live": "LIVE",
"waiting": "Ожидание задач...",
"activity": {
"authMiddleware": "Реализация auth middleware...",
"unitTests": "Написание unit-тестов для API...",
"reviewPr": "Ревью изменений PR #42...",
"ciPipeline": "Настройка CI/CD pipeline...",
"refactorDatabase": "Рефакторинг слоя базы данных..."
},
"tasks": {
"authApi": "Auth API",
"unitTests": "Unit-тесты",
"ciSetup": "CI setup"
},
"columns": {
"todo": "TODO",
"progress": "В РАБОТЕ",
"review": "РЕВЬЮ",
"done": "ГОТОВО"
}
}
},
"download": {
"title": "Скачать",
"detected": "Определено",
"systemRequirements": "Системные требования",
"version": "Версия {version}"
"version": "Версия {version}",
"readyToStart": "Готов начать!"
},
"theme": {
"dark": "Тёмная",
@ -100,7 +142,10 @@
"sectionSubtitle": "Реальные скриншоты приложения — канбан-доска, код-ревью, команды агентов и многое другое."
},
"common": {
"learnMore": "Подробнее"
"learnMore": "Подробнее",
"statusLabel": "Статус:",
"previous": "Предыдущий",
"next": "Следующий"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "Я жду",
"links": {
"github": "GitHub",
"author": "Автор",
"docs": "Документация"
}
},

View file

@ -7,7 +7,16 @@
"download": "گر تے ہوئے",
"pricing": "مفت",
"faq": "FAQ",
"viewOnGithub": "دیکھیں GitHub"
"viewOnGithub": "دیکھیں GitHub",
"openMenu": "مینو کھولیں",
"closeMenu": "مینو بند کریں",
"short": {
"screenshots": "تصاویر",
"docs": "Docs",
"download": "ڈاؤن لوڈ",
"comparison": "موازنہ",
"pricing": "مفت"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "کھولیں"
},
"watchDemo": "دیمو",
"videoUnavailable": "ویڈیو غیر درج شدہ"
"videoUnavailable": "ویڈیو غیر درج شدہ",
"supportedProviders": "حمایت یافتہ AI providers",
"slogan": "بہت کم کام کر کے بہت کچھ کریں",
"platformDefault": "آپ کے platform کے لیے",
"guidesSetup": "Guides اور setup",
"videoFrameLabel": "Agent Teams demo دیکھیں",
"commandFeed": "Team command feed",
"liveDemo": "Live demo",
"demoVideoTitle": "Agent Teams demo video",
"demoTitle": "Agent Teams demo",
"demo": {
"ariaLabel": "Agent team demo",
"live": "LIVE",
"waiting": "Tasks کا انتظار...",
"activity": {
"authMiddleware": "Auth middleware implement ہو رہا ہے...",
"unitTests": "API کے لیے unit tests لکھے جا رہے ہیں...",
"reviewPr": "PR #42 changes review ہو رہے ہیں...",
"ciPipeline": "CI/CD pipeline setup ہو رہی ہے...",
"refactorDatabase": "Database layer refactor ہو رہی ہے..."
},
"tasks": {
"authApi": "Auth API",
"unitTests": "Unit tests",
"ciSetup": "CI setup"
},
"columns": {
"todo": "TODO",
"progress": "IN PROGRESS",
"review": "REVIEW",
"done": "DONE"
}
}
},
"download": {
"title": "گر تے ہوئے",
"detected": "غیر متصل",
"systemRequirements": "سسٹم تقاضوں",
"version": "ورژن {version}"
"version": "ورژن {version}",
"readyToStart": "شروع کرنے کے لیے تیار!"
},
"theme": {
"dark": "اندھیرا",
@ -100,7 +142,10 @@
"sectionSubtitle": "ایپ کی طرف سے حقیقی اسکرین — کابینہ بورڈ ، کوڈ جائزہ ، ایجنٹ ٹیموں اور زیادہ سے زیادہ"
},
"common": {
"learnMore": "مزید سیکھیں"
"learnMore": "مزید سیکھیں",
"statusLabel": "اسٹیٹس:",
"previous": "پچھلا",
"next": "اگلا"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "میں انتظار کر رہا ہوں",
"links": {
"github": "GitHub",
"author": "مصنف",
"docs": "دستاویز"
}
},

View file

@ -7,7 +7,16 @@
"download": "下载",
"pricing": "免费",
"faq": "常见问题",
"viewOnGithub": "View on GitHub"
"viewOnGithub": "View on GitHub",
"openMenu": "打开菜单",
"closeMenu": "关闭菜单",
"short": {
"screenshots": "截图",
"docs": "文档",
"download": "下载",
"comparison": "比较",
"pricing": "免费"
}
},
"hero": {
"badge": "Agent Teams",
@ -22,13 +31,46 @@
"openSource": "开源"
},
"watchDemo": "观看演示",
"videoUnavailable": "视频不可用"
"videoUnavailable": "视频不可用",
"supportedProviders": "支持的 AI 提供商",
"slogan": "用很少操作完成大量工作",
"platformDefault": "适用于你的平台",
"guidesSetup": "指南和设置",
"videoFrameLabel": "观看 Agent Teams 演示",
"commandFeed": "团队命令流",
"liveDemo": "实时演示",
"demoVideoTitle": "Agent Teams 演示视频",
"demoTitle": "Agent Teams 演示",
"demo": {
"ariaLabel": "代理团队演示",
"live": "LIVE",
"waiting": "等待任务...",
"activity": {
"authMiddleware": "正在实现身份验证中间件...",
"unitTests": "正在为 API 编写单元测试...",
"reviewPr": "正在审查 PR #42 更改...",
"ciPipeline": "正在设置 CI/CD 流水线...",
"refactorDatabase": "正在重构数据库层..."
},
"tasks": {
"authApi": "认证 API",
"unitTests": "单元测试",
"ciSetup": "CI 设置"
},
"columns": {
"todo": "待办",
"progress": "进行中",
"review": "审查",
"done": "完成"
}
}
},
"download": {
"title": "下载",
"detected": "已检测",
"systemRequirements": "系统要求",
"version": "版本 {version}"
"version": "版本 {version}",
"readyToStart": "准备开始!"
},
"theme": {
"dark": "深色",
@ -100,7 +142,10 @@
"sectionSubtitle": "应用的真实截图——看板、代码审查、智能体团队等等。"
},
"common": {
"learnMore": "了解更多"
"learnMore": "了解更多",
"statusLabel": "状态:",
"previous": "上一个",
"next": "下一个"
},
"footer": {
"copyright": "© {year} Agent Teams",
@ -108,6 +153,7 @@
"robotBubble": "我在等你",
"links": {
"github": "GitHub",
"author": "作者",
"docs": "文档"
}
},

View file

@ -45,7 +45,7 @@
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^5.8.2",
"vitest": "^3.1.4"
"vitest": "^3.2.5"
},
"engines": {
"node": ">=24.15.0 <25"

View file

@ -222,7 +222,7 @@
"@types/react-dom": "^19.0.0",
"@types/ssh2": "^1.15.5",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^3.1.4",
"@vitest/coverage-v8": "^3.2.5",
"autoprefixer": "^10.4.17",
"electron": "^40.10.0",
"electron-builder": "^26.8.1",
@ -254,7 +254,7 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^6.4.2",
"vitest": "^3.1.4"
"vitest": "^3.2.5"
},
"build": {
"appId": "com.agent-teams.app",
@ -413,7 +413,7 @@
"flatted": "3.4.2",
"follow-redirects": "1.16.0",
"handlebars": "4.7.9",
"hono": "4.12.18",
"hono": "4.12.23",
"ip-address": "10.1.1",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",

View file

@ -18,7 +18,7 @@ overrides:
flatted: 3.4.2
follow-redirects: 1.16.0
handlebars: 4.7.9
hono: 4.12.18
hono: 4.12.23
ip-address: 10.1.1
lodash: ^4.18.1
lodash-es: ^4.18.1
@ -443,8 +443,8 @@ importers:
specifier: ^4.3.1
version: 4.7.0(vite@6.4.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/coverage-v8':
specifier: ^3.1.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
specifier: ^3.2.5
version: 3.2.6(vitest@3.2.6(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
autoprefixer:
specifier: ^10.4.17
version: 10.4.23(postcss@8.5.10)
@ -539,8 +539,8 @@ importers:
specifier: ^6.4.2
version: 6.4.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vitest:
specifier: ^3.1.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
specifier: ^3.2.5
version: 3.2.6(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
agent-teams-controller: {}
@ -654,8 +654,8 @@ importers:
specifier: ^5.8.2
version: 5.9.3
vitest:
specifier: ^3.1.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
specifier: ^3.2.5
version: 3.2.6(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
packages/agent-graph:
dependencies:
@ -1886,7 +1886,7 @@ packages:
resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: 4.12.18
hono: 4.12.23
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
@ -2149,8 +2149,8 @@ packages:
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@istanbuljs/schema@0.1.3':
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
'@istanbuljs/schema@0.1.6':
resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==}
engines: {node: '>=8'}
'@jridgewell/gen-mapping@0.3.13':
@ -5061,20 +5061,20 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
'@vitest/coverage-v8@3.2.4':
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
'@vitest/coverage-v8@3.2.6':
resolution: {integrity: sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==}
peerDependencies:
'@vitest/browser': 3.2.4
vitest: 3.2.4
'@vitest/browser': 3.2.6
vitest: 3.2.6
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/expect@3.2.6':
resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==}
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
'@vitest/mocker@3.2.6':
resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
@ -5084,20 +5084,20 @@ packages:
vite:
optional: true
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/pretty-format@3.2.6':
resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/runner@3.2.6':
resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/snapshot@3.2.6':
resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/spy@3.2.6':
resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
'@vitest/utils@3.2.6':
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
'@volar/language-core@2.4.28':
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
@ -5506,8 +5506,8 @@ packages:
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
ast-v8-to-istanbul@0.3.10:
resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==}
ast-v8-to-istanbul@0.3.12:
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
ast-walker-scope@0.6.2:
resolution: {integrity: sha512-1UWOyC50xI3QZkRuDj6PqDtpm1oHWtYs+NQGwqL/2R11eN3Q81PHAHPM0SWW3BNQm53UDwS//Jv8L4CCVLM1bQ==}
@ -7548,8 +7548,8 @@ packages:
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
hono@4.12.18:
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
hono@4.12.23:
resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==}
engines: {node: '>=16.9.0'}
hookable@5.5.3:
@ -8030,6 +8030,9 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -10476,8 +10479,8 @@ packages:
engines: {node: '>=10'}
hasBin: true
test-exclude@7.0.1:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
test-exclude@7.0.2:
resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==}
engines: {node: '>=18'}
text-decoder@1.2.7:
@ -10513,10 +10516,6 @@ packages:
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'}
tinyexec@1.1.2:
resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
engines: {node: '>=18'}
@ -11221,16 +11220,16 @@ packages:
postcss:
optional: true
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
vitest@3.2.6:
resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
'@vitest/browser': 3.2.6
'@vitest/ui': 3.2.6
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
@ -11591,7 +11590,7 @@ snapshots:
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
tinyexec: 1.1.2
'@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)':
dependencies:
@ -11654,15 +11653,15 @@ snapshots:
'@babel/generator@7.28.6':
dependencies:
'@babel/parser': 7.28.6
'@babel/types': 7.28.6
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/generator@7.29.1':
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
@ -11813,7 +11812,7 @@ snapshots:
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@babel/traverse@7.28.6':
@ -11821,9 +11820,9 @@ snapshots:
'@babel/code-frame': 7.28.6
'@babel/generator': 7.29.1
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.6
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
'@babel/types': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@ -11833,7 +11832,7 @@ snapshots:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
@ -12832,9 +12831,9 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
'@hono/node-server@1.19.13(hono@4.12.18)':
'@hono/node-server@1.19.13(hono@4.12.23)':
dependencies:
hono: 4.12.18
hono: 4.12.23
'@humanfs/core@0.19.1': {}
@ -13072,7 +13071,7 @@ snapshots:
'@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.3.0)(@vue/compiler-dom@3.5.34)(vue-i18n@10.0.8(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3))':
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
optionalDependencies:
'@intlify/shared': 11.3.0
'@vue/compiler-dom': 3.5.34
@ -13094,7 +13093,7 @@ snapshots:
dependencies:
minipass: 7.1.3
'@istanbuljs/schema@0.1.3': {}
'@istanbuljs/schema@0.1.6': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
@ -13267,7 +13266,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.13(hono@4.12.18)
'@hono/node-server': 1.19.13(hono@4.12.23)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
@ -13277,7 +13276,7 @@ snapshots:
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.3.0(express@5.2.1)
hono: 4.12.18
hono: 4.12.23
jose: 6.2.0
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@ -13532,8 +13531,8 @@ snapshots:
pkg-types: 2.3.0
rc9: 3.0.0
scule: 1.3.0
semver: 7.7.4
tinyglobby: 0.2.15
semver: 7.8.0
tinyglobby: 0.2.16
ufo: 1.6.3
unctx: 2.5.0
untyped: 2.0.0
@ -15662,16 +15661,16 @@ snapshots:
'@types/babel__generator@7.27.0':
dependencies:
'@babel/types': 7.28.6
'@babel/types': 7.29.0
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.28.6
'@babel/types': 7.28.6
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@types/babel__traverse@7.28.0':
dependencies:
'@babel/types': 7.28.6
'@babel/types': 7.29.0
'@types/cacheable-request@6.0.3':
dependencies:
@ -16080,7 +16079,7 @@ snapshots:
'@typescript-eslint/visitor-keys': 8.57.1
debug: 4.4.3
minimatch: 10.2.3
semver: 7.7.4
semver: 7.8.0
tinyglobby: 0.2.16
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
@ -16258,11 +16257,11 @@ snapshots:
vite: 7.3.3(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vue: 3.5.34(typescript@5.9.3)
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
'@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
ast-v8-to-istanbul: 0.3.10
ast-v8-to-istanbul: 0.3.12
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
@ -16271,59 +16270,59 @@ snapshots:
magic-string: 0.30.21
magicast: 0.3.5
std-env: 3.10.0
test-exclude: 7.0.1
test-exclude: 7.0.2
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vitest: 3.2.6(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
'@vitest/expect@3.2.4':
'@vitest/expect@3.2.6':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
'@vitest/spy': 3.2.6
'@vitest/utils': 3.2.6
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
'@vitest/mocker@3.2.6(vite@6.4.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 3.2.4
'@vitest/spy': 3.2.6
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite: 6.4.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
'@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
'@vitest/mocker@3.2.6(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 3.2.4
'@vitest/spy': 3.2.6
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
'@vitest/pretty-format@3.2.4':
'@vitest/pretty-format@3.2.6':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.4':
'@vitest/runner@3.2.6':
dependencies:
'@vitest/utils': 3.2.4
'@vitest/utils': 3.2.6
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/snapshot@3.2.4':
'@vitest/snapshot@3.2.6':
dependencies:
'@vitest/pretty-format': 3.2.4
'@vitest/pretty-format': 3.2.6
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.4':
'@vitest/spy@3.2.6':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.4':
'@vitest/utils@3.2.6':
dependencies:
'@vitest/pretty-format': 3.2.4
'@vitest/pretty-format': 3.2.6
loupe: 3.2.1
tinyrainbow: 2.0.0
@ -16378,14 +16377,14 @@ snapshots:
'@babel/core': 7.29.0
'@babel/helper-module-imports': 7.28.6
'@babel/helper-plugin-utils': 7.28.6
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@vue/compiler-sfc': 3.5.30
transitivePeerDependencies:
- supports-color
'@vue/compiler-core@3.5.30':
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@vue/shared': 3.5.30
entities: 7.0.1
estree-walker: 2.0.2
@ -16411,7 +16410,7 @@ snapshots:
'@vue/compiler-sfc@3.5.30':
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@vue/compiler-core': 3.5.30
'@vue/compiler-dom': 3.5.30
'@vue/compiler-ssr': 3.5.30
@ -16859,30 +16858,30 @@ snapshots:
ast-kit@1.4.3:
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
pathe: 2.0.3
ast-kit@2.2.0:
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
pathe: 2.0.3
ast-types-flow@0.0.8: {}
ast-v8-to-istanbul@0.3.10:
ast-v8-to-istanbul@0.3.12:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 9.0.1
js-tokens: 10.0.0
ast-walker-scope@0.6.2:
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
ast-kit: 1.4.3
ast-walker-scope@0.8.3:
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
ast-kit: 2.2.0
astral-regex@2.0.0:
@ -18400,7 +18399,7 @@ snapshots:
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3
minimatch: 9.0.7
semver: 7.7.4
semver: 7.8.0
stable-hash-x: 0.2.0
unrs-resolver: 1.11.1
optionalDependencies:
@ -18420,7 +18419,7 @@ snapshots:
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3
minimatch: 9.0.7
semver: 7.7.4
semver: 7.8.0
stable-hash-x: 0.2.0
unrs-resolver: 1.11.1
optionalDependencies:
@ -18472,7 +18471,7 @@ snapshots:
html-entities: 2.6.0
object-deep-merge: 2.0.0
parse-imports-exports: 0.2.4
semver: 7.7.4
semver: 7.8.0
spdx-expression-parse: 4.0.0
to-valid-identifier: 1.0.0
transitivePeerDependencies:
@ -18590,7 +18589,7 @@ snapshots:
pluralize: 8.0.0
regexp-tree: 0.1.27
regjsparser: 0.13.0
semver: 7.7.4
semver: 7.8.0
strip-indent: 4.1.1
eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@2.7.0)))(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.7.0))):
@ -18600,7 +18599,7 @@ snapshots:
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 7.1.1
semver: 7.7.4
semver: 7.8.0
vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.7.0))
xml-name-validator: 4.0.0
optionalDependencies:
@ -18956,7 +18955,7 @@ snapshots:
execa: 9.6.1
file-type: 21.3.2
fuse.js: 7.3.0
hono: 4.12.18
hono: 4.12.23
mcp-proxy: 6.4.1
strict-event-emitter-types: 2.0.0
undici: 7.24.0
@ -19269,7 +19268,7 @@ snapshots:
es6-error: 4.1.1
matcher: 3.0.0
roarr: 2.15.4
semver: 7.7.4
semver: 7.8.0
serialize-error: 7.0.1
optional: true
@ -19513,7 +19512,7 @@ snapshots:
hls.js@1.6.16: {}
hono@4.12.18: {}
hono@4.12.23: {}
hookable@5.5.3: {}
@ -19992,6 +19991,8 @@ snapshots:
joycon@3.1.1: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@ -20042,7 +20043,7 @@ snapshots:
acorn: 8.16.0
eslint-visitor-keys: 3.4.3
espree: 9.6.1
semver: 7.7.4
semver: 7.8.0
jsonc-parser@3.3.1: {}
@ -20325,19 +20326,19 @@ snapshots:
magicast@0.3.5:
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
source-map-js: 1.2.1
magicast@0.5.2:
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.4
semver: 7.8.0
mark.js@8.11.1: {}
@ -21243,7 +21244,7 @@ snapshots:
dependencies:
citty: 0.2.2
pathe: 2.0.3
tinyexec: 1.0.2
tinyexec: 1.1.2
nypm@0.6.6:
dependencies:
@ -23147,11 +23148,11 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
test-exclude@7.0.1:
test-exclude@7.0.2:
dependencies:
'@istanbuljs/schema': 0.1.3
'@istanbuljs/schema': 0.1.6
glob: 10.5.0
minimatch: 9.0.7
minimatch: 10.2.3
text-decoder@1.2.7:
dependencies:
@ -23185,8 +23186,6 @@ snapshots:
tinyexec@0.3.2: {}
tinyexec@1.0.2: {}
tinyexec@1.1.2: {}
tinyglobby@0.2.15:
@ -23743,7 +23742,7 @@ snapshots:
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite: 6.4.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@ -23764,7 +23763,7 @@ snapshots:
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@ -23871,26 +23870,9 @@ snapshots:
tsx: 4.21.0
yaml: 2.9.0
vite@7.3.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
esbuild: 0.27.4
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.10
rollup: 4.59.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.12.4
fsevents: 2.3.3
jiti: 1.21.7
sass: 1.98.0
terser: 5.46.0
tsx: 4.21.0
yaml: 2.9.0
vite@7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
esbuild: 0.27.4
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.10
@ -23912,7 +23894,7 @@ snapshots:
picomatch: 4.0.4
postcss: 8.5.10
rollup: 4.59.0
tinyglobby: 0.2.15
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 25.0.7
fsevents: 2.3.3
@ -24011,16 +23993,16 @@ snapshots:
- universal-cookie
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
vitest@3.2.6(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
'@vitest/expect': 3.2.6
'@vitest/mocker': 3.2.6(vite@6.4.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/pretty-format': 3.2.6
'@vitest/runner': 3.2.6
'@vitest/snapshot': 3.2.6
'@vitest/spy': 3.2.6
'@vitest/utils': 3.2.6
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
@ -24030,10 +24012,10 @@ snapshots:
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinyglobby: 0.2.16
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite: 6.4.2(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite-node: 3.2.4(@types/node@24.12.4)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
@ -24054,16 +24036,16 @@ snapshots:
- tsx
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
vitest@3.2.6(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
'@vitest/expect': 3.2.6
'@vitest/mocker': 3.2.6(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/pretty-format': 3.2.6
'@vitest/runner': 3.2.6
'@vitest/snapshot': 3.2.6
'@vitest/spy': 3.2.6
'@vitest/utils': 3.2.6
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
@ -24073,10 +24055,10 @@ snapshots:
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinyglobby: 0.2.16
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vite-node: 3.2.4(@types/node@24.12.4)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
@ -24119,7 +24101,7 @@ snapshots:
eslint-visitor-keys: 5.0.1
espree: 11.2.0
esquery: 1.7.0
semver: 7.7.4
semver: 7.8.0
transitivePeerDependencies:
- supports-color

View file

@ -85,6 +85,10 @@ export interface TeamGraphData extends TeamViewSnapshot {
runtimeEntriesByMember?: Record<string, TeamAgentRuntimeEntry>;
}
export interface TeamGraphAdapterText {
hiddenBlockingLinks(count: number): string;
}
function toGraphLaunchVisualState(
visualState: ReturnType<typeof buildMemberLaunchPresentation>['launchVisualState'] | undefined
): GraphNode['launchVisualState'] {
@ -141,7 +145,8 @@ export class TeamGraphAdapter {
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
layoutMode: GraphLayoutMode = DEFAULT_TEAM_GRAPH_LAYOUT_MODE,
gridOwnerOrder?: readonly string[],
activeTaskLogActivity?: Record<string, true>
activeTaskLogActivity?: Record<string, true>,
text?: TeamGraphAdapterText
): GraphDataPort {
if (teamData?.teamName !== teamName) {
return TeamGraphAdapter.#emptyResult(teamName);
@ -227,7 +232,8 @@ export class TeamGraphAdapter {
memberNodeIdByAlias,
leadId,
leadName,
activeTaskLogActivity
activeTaskLogActivity,
text
);
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
@ -673,7 +679,8 @@ export class TeamGraphAdapter {
memberNodeIdByAlias?: ReadonlyMap<string, string>,
leadId?: string,
leadName?: string,
activeTaskLogActivity?: Record<string, true>
activeTaskLogActivity?: Record<string, true>,
text?: TeamGraphAdapterText
): void {
const taskStateById = new Map<
string,
@ -917,7 +924,8 @@ export class TeamGraphAdapter {
label:
edge.aggregateCount > 1 &&
(edge.source.includes(':overflow:') || edge.target.includes(':overflow:'))
? `${edge.aggregateCount} hidden blocking links`
? (text?.hiddenBlockingLinks(edge.aggregateCount) ??
`${edge.aggregateCount} hidden blocking links`)
: undefined,
}))
);

View file

@ -5,6 +5,7 @@
import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { useTeamAgentRuntimeWatcher } from '@renderer/components/team/useTeamAgentRuntimeWatcher';
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
@ -65,9 +66,17 @@ export function useTeamGraphAdapter(
options?: UseTeamGraphAdapterOptions
): GraphDataPort {
const isActive = options?.active ?? true;
const { t } = useAppTranslation('team');
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
const inactiveGraphData = useMemo(() => emptyGraphData(teamName), [teamName]);
const lastActiveGraphDataRef = useRef<GraphDataPort>(inactiveGraphData);
const adapterText = useMemo(
() => ({
hiddenBlockingLinks: (count: number) =>
t('agentGraph.blockingEdge.hiddenBlockingLinks', { count }),
}),
[t]
);
const {
teamSnapshot,
@ -216,7 +225,8 @@ export function useTeamGraphAdapter(
effectiveSlotAssignments,
graphLayoutMode ?? DEFAULT_TEAM_GRAPH_LAYOUT_MODE,
gridOwnerOrder,
activeTaskLogActivity
activeTaskLogActivity,
adapterText
);
}, [
isActive,
@ -236,6 +246,7 @@ export function useTeamGraphAdapter(
graphLayoutMode,
gridOwnerOrder,
activeTaskLogActivity,
adapterText,
]);
useLayoutEffect(() => {

View file

@ -21,26 +21,47 @@ function isOverflowNode(
return Boolean(node?.kind === 'task' && node.isOverflowStack);
}
function describeNode(node: GraphNode | undefined, fallback: string): string {
interface BlockingEdgeLabels {
hiddenTaskStack: string;
hiddenTasks: (count: number) => string;
task: string;
openBlockerStack: string;
openBlockedStack: string;
openBlockerTask: string;
openBlockedTask: string;
}
function describeNode(
node: GraphNode | undefined,
fallback: string,
labels: Pick<BlockingEdgeLabels, 'hiddenTaskStack' | 'hiddenTasks' | 'task'>
): string {
if (!node) return fallback;
if (isOverflowNode(node)) {
return node.overflowCount && node.overflowCount > 1
? `${node.overflowCount} hidden tasks`
: 'Hidden task stack';
? labels.hiddenTasks(node.overflowCount)
: labels.hiddenTaskStack;
}
if (isTaskNode(node)) {
return `${node.displayId ?? node.label} - ${node.sublabel ?? 'Task'}`;
return `${node.displayId ?? node.label} - ${node.sublabel ?? labels.task}`;
}
return node.label;
}
function getActionLabel(node: GraphNode | undefined, role: 'blocker' | 'blocked'): string | null {
function getActionLabel(
node: GraphNode | undefined,
role: 'blocker' | 'blocked',
labels: Pick<
BlockingEdgeLabels,
'openBlockerStack' | 'openBlockedStack' | 'openBlockerTask' | 'openBlockedTask'
>
): string | null {
if (!node) return null;
if (isOverflowNode(node)) {
return role === 'blocker' ? 'Open blocker stack' : 'Open blocked stack';
return role === 'blocker' ? labels.openBlockerStack : labels.openBlockedStack;
}
if (isTaskNode(node)) {
return role === 'blocker' ? 'Open blocker task' : 'Open blocked task';
return role === 'blocker' ? labels.openBlockerTask : labels.openBlockedTask;
}
return null;
}
@ -71,10 +92,19 @@ export const GraphBlockingEdgePopover = ({
[teamData?.tasks]
);
const relationCount = edge.aggregateCount ?? 1;
const sourceLabel = describeNode(sourceNode, edge.source);
const targetLabel = describeNode(targetNode, edge.target);
const sourceActionLabel = getActionLabel(sourceNode, 'blocker');
const targetActionLabel = getActionLabel(targetNode, 'blocked');
const labels: BlockingEdgeLabels = {
hiddenTaskStack: t('agentGraph.blockingEdge.hiddenTaskStack'),
hiddenTasks: (count) => t('agentGraph.blockingEdge.hiddenTasks', { count }),
task: t('agentGraph.blockingEdge.task'),
openBlockerStack: t('agentGraph.blockingEdge.openBlockerStack'),
openBlockedStack: t('agentGraph.blockingEdge.openBlockedStack'),
openBlockerTask: t('agentGraph.blockingEdge.openBlockerTask'),
openBlockedTask: t('agentGraph.blockingEdge.openBlockedTask'),
};
const sourceLabel = describeNode(sourceNode, edge.source, labels);
const targetLabel = describeNode(targetNode, edge.target, labels);
const sourceActionLabel = getActionLabel(sourceNode, 'blocker', labels);
const targetActionLabel = getActionLabel(targetNode, 'blocked', labels);
const sourceHiddenTasks = resolveEdgeTaskPreview(sourceNode, edge.sourceTaskIds, tasksById);
const targetHiddenTasks = resolveEdgeTaskPreview(targetNode, edge.targetTaskIds, tasksById);
@ -111,7 +141,7 @@ export const GraphBlockingEdgePopover = ({
variant="outline"
className="border-red-500/30 px-1.5 py-0 text-[10px] text-red-300"
>
{relationCount} links
{t('agentGraph.blockingEdge.links', { count: relationCount })}
</Badge>
)}
</div>

View file

@ -132,7 +132,14 @@ function hasOpenCodeEmptyStateWarning(preview: MemberLogPreviewMember | undefine
function resolveEmptyText(
preview: MemberLogPreviewMember | undefined,
loading: boolean,
error: string | null
error: string | null,
labels: {
unsupportedProvider: string;
openCodeLogsDelayed: string;
logsUnavailable: string;
loadingLogs: string;
noRecentLogs: string;
}
): string {
const hasCodexUnsupportedWarning = preview?.warnings.some(
(warning) => warning.code === 'codex_member_wide_not_supported'
@ -142,34 +149,47 @@ function resolveEmptyText(
(preview?.coverage.length ?? 0) > 0 &&
preview?.coverage.every((coverage) => coverage.provider === 'codex_native_trace');
if (hasOnlyCodexUnsupportedCoverage) {
return 'Unsupported provider';
return labels.unsupportedProvider;
}
if ((preview?.items.length ?? 0) === 0 && hasOpenCodeDeliveryDelayedWarning(preview)) {
return 'OpenCode logs delayed';
return labels.openCodeLogsDelayed;
}
if ((preview?.items.length ?? 0) === 0 && hasOpenCodeRuntimeWarning(preview)) {
return 'Logs unavailable';
return labels.logsUnavailable;
}
if (loading && !preview) return 'Loading logs';
if (error && !preview) return 'Logs unavailable';
return 'No recent logs';
if (loading && !preview) return labels.loadingLogs;
if (error && !preview) return labels.logsUnavailable;
return labels.noRecentLogs;
}
function fallbackDisplayTitle(item: MemberLogPreviewItem): string {
function fallbackDisplayTitle(
item: MemberLogPreviewItem,
labels: {
toolError: string;
toolResult: string;
toolUse: string;
thinking: string;
error: string;
logEvent: string;
}
): string {
if (item.kind === 'tool_result') {
return item.tone === 'error' ? 'Tool error' : 'Tool result';
return item.tone === 'error' ? labels.toolError : labels.toolResult;
}
if (item.kind === 'tool_use') {
return item.toolName?.trim() || 'Tool use';
return item.toolName?.trim() || labels.toolUse;
}
if (item.kind === 'thinking') {
return 'Thinking';
return labels.thinking;
}
return item.tone === 'error' ? 'Error' : 'Log event';
return item.tone === 'error' ? labels.error : labels.logEvent;
}
function compactDisplayTitle(item: MemberLogPreviewItem): string {
const title = item.title.trim() || fallbackDisplayTitle(item);
function compactDisplayTitle(
item: MemberLogPreviewItem,
labels: Parameters<typeof fallbackDisplayTitle>[1]
): string {
const title = item.title.trim() || fallbackDisplayTitle(item, labels);
if (title.toLowerCase() === 'tool result') {
return title;
}
@ -205,7 +225,13 @@ function trimRepeatedTitlePrefix(preview: string, title: string): string {
function compactPreviewText(
item: MemberLogPreviewItem,
displayTitle: string,
rawDisplayTitle = displayTitle
rawDisplayTitle = displayTitle,
labels: {
noErrorOutput: string;
noOutput: string;
noInput: string;
logEvent: string;
}
): string {
const preview = item.preview?.trim();
if (preview) {
@ -217,12 +243,12 @@ function compactPreviewText(
return compact || preview;
}
if (item.kind === 'tool_result') {
return item.tone === 'error' ? 'No error output' : 'No output';
return item.tone === 'error' ? labels.noErrorOutput : labels.noOutput;
}
if (item.kind === 'tool_use') {
return 'No input';
return labels.noInput;
}
return item.sourceLabel || 'Log event';
return item.sourceLabel || labels.logEvent;
}
function truncateCompactRowPreview(
@ -281,6 +307,25 @@ export const GraphMemberLogPreviewHud = ({
onOpenMemberProfile,
}: GraphMemberLogPreviewHudProps): React.JSX.Element | null => {
const { t } = useAppTranslation('team');
const logPreviewLabels = useMemo(
() => ({
unsupportedProvider: t('agentGraph.logPreview.unsupportedProvider'),
openCodeLogsDelayed: t('agentGraph.logPreview.openCodeLogsDelayed'),
logsUnavailable: t('agentGraph.logPreview.logsUnavailable'),
loadingLogs: t('agentGraph.logPreview.loading'),
noRecentLogs: t('agentGraph.logPreview.noRecentLogs'),
toolError: t('agentGraph.logPreview.toolError'),
toolResult: t('agentGraph.logPreview.toolResult'),
toolUse: t('agentGraph.logPreview.toolUse'),
thinking: t('agentGraph.logPreview.thinking'),
error: t('agentGraph.logPreview.error'),
logEvent: t('agentGraph.logPreview.logEvent'),
noErrorOutput: t('agentGraph.logPreview.noErrorOutput'),
noOutput: t('agentGraph.logPreview.noOutput'),
noInput: t('agentGraph.logPreview.noInput'),
}),
[t]
);
const worldLayerRef = useRef<HTMLDivElement | null>(null);
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
const visibleKeyRef = useRef('');
@ -514,9 +559,14 @@ export const GraphMemberLogPreviewHud = ({
const renderItem = useCallback(
(memberName: string, item: MemberLogPreviewItem) => {
const relativeTime = formatRelativeTime(item.timestamp);
const rawDisplayTitle = compactDisplayTitle(item);
const rawDisplayTitle = compactDisplayTitle(item, logPreviewLabels);
const displayTitle = truncateCompactTitle(rawDisplayTitle);
const fullPreviewText = compactPreviewText(item, displayTitle, rawDisplayTitle);
const fullPreviewText = compactPreviewText(
item,
displayTitle,
rawDisplayTitle,
logPreviewLabels
);
const previewText = truncateCompactRowPreview(fullPreviewText, displayTitle, relativeTime);
const titleText = compactRowLabel([rawDisplayTitle, relativeTime, fullPreviewText]);
const isHighlighted = highlightedItemIds.has(buildRenderedItemKey(memberName, item.id));
@ -565,7 +615,7 @@ export const GraphMemberLogPreviewHud = ({
</button>
);
},
[highlightedItemIds, openLogs]
[highlightedItemIds, logPreviewLabels, openLogs]
);
if (!enabled || ownerNodes.length === 0) {
@ -631,7 +681,7 @@ export const GraphMemberLogPreviewHud = ({
className={`${INTERACTIVE_LOG_CONTROL_CLASS} flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60`}
onClick={() => openLogs(memberName)}
>
{resolveEmptyText(preview, loading, error)}
{resolveEmptyText(preview, loading, error, logPreviewLabels)}
</button>
)}
{preview && preview.overflowCount > 0 ? (

View file

@ -223,8 +223,8 @@ export function resolveAnthropicFastMode(params: {
'Fast mode is not supported by this Anthropic runtime.';
} else if (!params.selection.supportsFastMode) {
disabledReason = params.selection.displayName
? `Fast mode is available only for Opus 4.6. Selected model resolves to ${params.selection.displayName}.`
: 'Fast mode is available only for Opus 4.6.';
? `Fast mode is available only for Opus 4.8. Selected model resolves to ${params.selection.displayName}.`
: 'Fast mode is available only for Opus 4.8.';
} else if (!params.selection.providerFastModeAvailable) {
disabledReason =
params.selection.providerFastModeReason ?? 'Fast mode is currently unavailable.';

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,17 @@
{
"cliStatus": {
"actions": {
"alreadyLoggedIn": "已经登录了吗?",
"becomeSponsor": "成为提案国",
"alreadyLoggedIn": "已经登录",
"becomeSponsor": "成为赞助商",
"cancel": "取消",
"checkNow": "现在检查",
"checkNow": "立即查看",
"checkUpdates": "检查更新",
"checking": "正在检查...",
"checking": "检查…",
"connect": "连接",
"extensions": "扩展",
"login": "登录",
"manage": "管理",
"manageProviders": "管理商",
"manageProviders": "管理供商",
"plan": "计划",
"recheck": "重新检查",
"recheckProvider": "重新检查 {{provider}}",
@ -20,161 +20,161 @@
"useCode": "使用代码"
},
"atlas": {
"alt": "地图集云",
"description": "Atlas Cloud是一个全模式的AI推论平台,它让开发者获得一个单一的AI API来访问视频生成,图像生成,以及LLM API. 与其管理多个供应商集成,不如连接一次,并获得所有模式300+全方位模型的统一访问. 请检查access-date=中的日期值 (帮助) Atlas Cloud新编码计划推广 更方便预算 API访问.",
"openCodeProvider": "打开代码提供者",
"plan": "阿特拉斯云编码计划",
"sponsor": "发起人"
"alt": "Atlas Cloud",
"description": "Atlas Cloud 是一个全模态 AI 推理平台,为开发者提供单一 AI API 来访问视频生成、图像生成和 LLM API。您无需管理多个提供商集成只需连接一次即可统一访问跨所有模态的 300 多个精选模型。查看 Atlas Cloud 的新编码计划促销活动,以获取更实惠的 API 访问权限。",
"openCodeProvider": "OpenCode 提供商",
"plan": "Atlas Cloud 编码计划",
"sponsor": "赞助"
},
"errors": {
"checkStatusFailed": "检查 CLI 状态失败",
"checkStatusFailed": "无法检查 CLI 状态",
"installationFailed": "安装失败",
"refreshFailed": "检查更新失败 。 检查您的网络连接并再次尝试 。",
"runtimeUpdatedRefreshFailed": "运行时间已更新, 但无法刷新提供者状态 。"
"refreshFailed": "无法检查更新。检查您的网络连接并重试。",
"runtimeUpdatedRefreshFailed": "运行时已更新,但无法刷新提供商状态。"
},
"hints": {
"backgroundStatus": "{{runtime}}状态将在背景中检查.",
"codexApiKeyFallback": "{{hint}} ZXCV 1ZXCV 如果您切换了认证模式,则可以使用密钥倒置。",
"codexAutoApiKey": "{{hint}} 苏维埃社会主义共和国 自动会继续使用API密钥,直到ChatGPT连接.",
"codexFinishLogin": "在浏览器中完成 ChatGPT 登录 。 如果提示, 请输入显示的代码 。",
"codexNoActiveLogin": "用法限制仅在Codex CLI看到一个活动中的ChatGPT账户后出现. 现在它没有报告ChatGPT的登录。",
"codexNoActiveManagedSession": "用法限制仅在Codex CLI看到一个活动中的ChatGPT账户后出现. 本地 Codex 账户数据已存在, 但目前没有选择活动管理会话 。",
"codexReconnectNeeded": "用法限制仅在 Codex 刷新当前选中的 ChatGPT 会话后才会出现 。 现在本地会议需要重新连接。",
"firstCheckSlow": "第一次检查可能要30秒",
"loginRequiredForTeams": "浏览会话和项目在不登录的情况下工作. 只需要登录即可运行代理团队.",
"troubleshootTitle": "如果你确定你登录, 尝试这些步骤:"
"backgroundStatus": "{{runtime}} 状态将在后台检查。",
"codexApiKeyFallback": "如果您切换认证模式,{{hint}} API 密钥备用选项可用。",
"codexAutoApiKey": "{{hint}} Auto 将继续使用 API 密钥,直到连接 ChatGPT。",
"codexFinishLogin": "在浏览器中完成 ChatGPT 登录。如有提示,请输入显示的代码。",
"codexNoActiveLogin": "仅当 Codex CLI 看到有效的 ChatGPT 帐户后,才会出现用量限制。目前,它报告没有有效的 ChatGPT 登录。",
"codexNoActiveManagedSession": "仅当 Codex CLI 看到有效的 ChatGPT 帐户后,才会出现用量限制。本地 Codex 帐户数据存在,但目前未选择有效的托管会话。",
"codexReconnectNeeded": "仅在 Codex 刷新当前选定的 ChatGPT 会话后才会出现用量限制。现在本地会话需要重新连接。",
"firstCheckSlow": "第一次检查最多可能 30 秒",
"loginRequiredForTeams": "无需登录即可浏览会话和项目。仅需要登录才能运行 Agent Team。",
"troubleshootTitle": "如果您确定已登录,请尝试以下步骤:"
},
"installer": {
"checkingLatest": "正在检查最新版本...",
"downloading": "正在下载 {{runtime}}...",
"installing": "正在安装 {{runtime}}...",
"checkingLatest": "正在检查最新版本",
"downloading": "正在下载 {{runtime}}",
"installing": "正在安装 {{runtime}}",
"success": "成功安装 {{runtime}} v{{version}}",
"verifying": "正在验证校验和..."
"verifying": "正在验证校验和"
},
"labels": {
"apiKeyRequired": "需要 API 密钥",
"comingSoon": "马上就来",
"collapseProviderDetails": "折叠提供者细节",
"expandProviderDetails": "扩展提供者细节",
"apiKeyRequired": "需要 API 密钥",
"comingSoon": "即将推出",
"collapseProviderDetails": "折叠提供商详细信息",
"expandProviderDetails": "展开提供商详细信息",
"generateLink": "生成链接",
"loadingRateLimits": "速率限制加载",
"loggedOut": "供应商已登录",
"loggedOut": "提供商已注销",
"loginAuthFailed": "认证失败",
"loginAuthUpdated": "更新认证",
"loginAuthUpdated": "认证已更新",
"loginComplete": "登录完成",
"loginFailed": "登录失败",
"loginTitle": "登录",
"logoutFailed": "注销失败",
"logoutTitle": "注销",
"logoutTitle": "退出",
"notLoggedIn": "未登录",
"openLogin": "打开登录",
"providerActionRequired": "需要提供者采取的行动",
"resets": "重新发送 {{time}}",
"runtimeLoginTitle": "{{runtime}} 苏维埃社会主义共和国 登录"
"providerActionRequired": "需要提供商采取行动",
"resets": "复位 {{time}}",
"runtimeLoginTitle": "{{runtime}} 登录"
},
"loading": {
"aiProviders": "正在检查 AI 提供者...",
"claudeCli": "正在检查克劳德CLI..."
"aiProviders": "检查 AI 提供商…",
"claudeCli": "检查 Claude CLI…"
},
"provider": {
"authenticated": "已认证",
"backend": "后端: {{backend}}",
"checkingAuthentication": "正在检查认证...",
"checkingProviders": "正在检查提供者...",
"configuredLocalCount": "{{count}} 本地配置",
"configuredLocalCount_few": "{{count}} 本地配置",
"configuredLocalCount_many": "{{count}} 本地配置",
"configuredLocalCount_one": "{{count}} 本地配置",
"configuredLocalCount_other": "{{count}} 本地配置",
"configuredLocalTitle": "从您的 OpenCode 配置导入本地 OpenCode 路由 。",
"connectedCount": "供应商:{{connected}}/{{denominator}}连接",
"freeModels": "免费模",
"freeModelsTitle": "OpenCode 包含一些免费的模型选项, 如在您的设置中可用时的 Big Pickle 。 通过OpenCode的OpenRouter也可以曝光自由模型,但并不是每个OpenCode/OpenRouter模型都是免费的. 可用性和限制可能会改变。",
"loadingModels": "正在装入模型...",
"modelsUnavailable": "此运行时构建无法使用的模型",
"runtime": "运行时间: {{runtime}}",
"verifiedCount": "{{count}} 经核查",
"verifiedCount_few": "{{count}} 经核查",
"verifiedCount_many": "{{count}} 经核查",
"verifiedCount_one": "{{count}} 经核查",
"verifiedCount_other": "{{count}} 经核查",
"verifiedTitle": "带有成功执行证明的 OpenCode 路由 。"
"backend": "后端{{backend}}",
"checkingAuthentication": "正在检查认证",
"checkingProviders": "正在检查提供商…",
"configuredLocalCount": "{{count}} 配置本地",
"configuredLocalCount_few": "{{count}} 配置本地",
"configuredLocalCount_many": "{{count}} 配置本地",
"configuredLocalCount_one": "{{count}} 配置本地",
"configuredLocalCount_other": "{{count}} 配置本地",
"configuredLocalTitle": "从 OpenCode 配置导入本地 OpenCode 路由。",
"connectedCount": "提供商:{{connected}}/{{denominator}} 连接",
"freeModels": "免费模",
"freeModelsTitle": "OpenCode 包含免费模型选项,例如您的设置中可用的 Big Pickle。 OpenRouter 通过 OpenCode 也可以公开免费模型,但并非每个 OpenCode/OpenRouter 模型都是免费的。可用性和限制可能会发生变化。",
"loadingModels": "正在加载模型…",
"modelsUnavailable": "模型不适用于此运行时构建",
"runtime": "运行时{{runtime}}",
"verifiedCount": "{{count}} 已验证",
"verifiedCount_few": "{{count}} 已验证",
"verifiedCount_many": "{{count}} 已验证",
"verifiedCount_one": "{{count}} 已验证",
"verifiedCount_other": "{{count}} 已验证",
"verifiedTitle": "OpenCode 路由具有成功的执行证明。"
},
"runtime": {
"configuredHealthCheckFailed": "配置的 {{runtime}} 失败启动健康检查.",
"configuredHealthCheckFailed": "配置的 {{runtime}} 启动运行状况检查失败。",
"configuredNotFound": "未找到配置的 {{runtime}}。",
"foundButFailed": "发现 {{runtime}} 失败启动",
"healthCheckFailedDescription": "该应用程序发现了配置的{{runtime}},但其启动健康检查失败. 修理或重新安装,然后重试。",
"foundButFailed": "找到 {{runtime}} 但启动失败",
"healthCheckFailedDescription": "应用找到配置的 {{runtime}},但其启动健康检查失败。修复或重新安装,然后重试。",
"install": "安装 {{runtime}}",
"installRequiredDescription": "{{runtime}}是团队提供和会话管理所需的. 安装开始 。",
"isRequired": "需要{{runtime}}",
"reinstall": "莱因斯托尔 {{runtime}}"
"installRequiredDescription": "团队配置和会话管理需要 {{runtime}}。安装它即可开始。",
"isRequired": "{{runtime}} 为必填项",
"reinstall": "重新安装 {{runtime}}"
},
"runtimeInstall": {
"checking": "检查",
"codexTitle": "在应用数据中安装代码CLI",
"downloading": "下载",
"downloadingPercent": "下载 {{percent}}%",
"checking": "检查",
"codexTitle": "将 Codex CLI 安装到应用数据中",
"downloading": "正在下载",
"downloadingPercent": "正在下载 {{percent}}%",
"install": "安装",
"installing": "安装",
"openCodeTitle": "安装 OpenCode 运行时间到应用数据",
"installing": "安装",
"openCodeTitle": "将 OpenCode 运行时安装到应用数据中",
"retryInstall": "重试安装"
},
"troubleshoot": {
"again": "再来一次",
"authStatusCommand": "您配置的 CLI 认证状态命令",
"checkLoggedIn": "- 检查它是否显示\"Logged in\"",
"click": "击",
"loginCommand": "运行时登录命令",
"logoutCommand": "运行时间登录命令",
"openTerminal": "打开终端并运行:",
"reloginPrefix": "如果上面写着登录但应用程序看不到的话,请试试:",
"sameRuntime": "确保您的终端中的 CLI 与应用程序使用的运行时相同",
"again": "再次",
"authStatusCommand": "您配置的 CLI 认证状态命令",
"checkLoggedIn": "- 检查是否显示“已登录”",
"click": "击",
"loginCommand": "运行时登录命令",
"logoutCommand": "运行时注销命令",
"openTerminal": "打开终端并运行",
"reloginPrefix": "如果显示已登录,但应用看不到它,请尝试:",
"sameRuntime": "确保终端中的 CLI 与应用使用的运行时相同",
"statusCacheHint": "- 有时状态会缓存几秒钟",
"then": "接下来"
"then": "然后"
},
"warnings": {
"multipleApiKeysMissing": "一个或多个提供者被设定为API密钥模式,但没有配置API密钥. 打开管理供应商以添加密钥或切换连接模式.",
"multipleApiKeysNeedAttention": "一个或多个提供者被设定为API密钥模式,需要关注. 打开管理供应商来审查保存的密钥或切换连接模式 。",
"notAuthenticated": "{{runtime}}已经安装,但您没有认证 。 团队提供和AI功能需要登录.",
"singleApiKeyMissing": "{{provider}}被设定为API密钥模式,但没有配置API密钥. 打开管理供应商以添加密钥或切换连接模式 。",
"singleApiKeyNeedsAttention": "{{provider}}设定为API密钥模式,但没有连接. 打开管理提供者来审查保存的密钥或切换连接模式 。"
"multipleApiKeysMissing": "一个或多个提供商设置为 API 密钥模式,但未配置 API 密钥。打开管理提供商以添加密钥或切换连接模式。",
"multipleApiKeysNeedAttention": "一个或多个提供商已设置为 API 密钥模式,需要引起注意。打开管理提供商以查看保存的密钥或切换连接模式。",
"notAuthenticated": "{{runtime}} 已安装,但您未经过认证。团队配置和 AI 功能需要登录。",
"singleApiKeyMissing": "{{provider}} 设置为 API 密钥模式,但未配置 API 密钥。打开管理提供商以添加密钥或切换连接模式。",
"singleApiKeyNeedsAttention": "{{provider}} 设置为 API 密钥模式,但未连接。打开管理提供商以查看保存的密钥或切换连接模式。"
}
},
"recentProjects": {
"selectFolderTitle": "选择工程文件夹",
"selectFolderTitle": "选择项目文件夹",
"selectFolder": "选择文件夹",
"failedToLoad": "装入工程失败",
"failedToLoad": "无法加载项目",
"retry": "重试",
"noProjects": "未找到工程",
"noMatches": "没有 {{query}}” 的匹配",
"noProjects": "未找到项目",
"noMatches": "没有匹配“{{query}}”",
"noRecentProjects": "未找到最近的项目",
"emptyDescription": "最近Claude和Codex的活动会在这里出现.",
"loadMore": "装入更多",
"emptyDescription": "最近的 Claude 和 Codex 活动将出现在这里。",
"loadMore": "加载更多",
"card": {
"deleted": "删除",
"projectFolderMissing": "项目文件夹不存在",
"deleted": "删除",
"projectFolderMissing": "项目文件夹存在",
"taskCounts": {
"active": "{{count}}活动",
"active_one": "{{count}}活动",
"active_other": "{{count}}活动",
"active_few": "{{count}}活动",
"active_many": "{{count}}活动",
"pending": "{{count}}待处理",
"pending_one": "{{count}}待处理",
"pending_other": "{{count}}待处理",
"pending_few": "{{count}}待处理",
"pending_many": "{{count}}待处理",
"done": "{{count}}已执行",
"done_one": "{{count}}已执行",
"done_other": "{{count}}已执行",
"done_few": "{{count}}已执行",
"done_many": "{{count}}已执行"
"active": "{{count}} 活跃",
"active_one": "{{count}} 活跃",
"active_other": "{{count}} 活跃",
"active_few": "{{count}} 活跃",
"active_many": "{{count}} 活跃",
"pending": "{{count}} 待定",
"pending_one": "{{count}} 待定",
"pending_other": "{{count}} 待定",
"pending_few": "{{count}} 待定",
"pending_many": "{{count}} 待定",
"done": "{{count}} 完成",
"done_one": "{{count}} 完成",
"done_other": "{{count}} 完成",
"done_few": "{{count}} 完成",
"done_many": "{{count}} 完成"
}
},
"title": "最近的项目",
"searchResults": "搜索结果",
"searchPlaceholder": "搜索项目..."
"searchPlaceholder": "搜索项目"
},
"actions": {
"selectTeam": "选择团队",
@ -182,16 +182,16 @@
"clearSearch": "清除搜索"
},
"windowsAdmin": {
"title": "建议使用 Windows 管理员模式",
"description": "OpenCode 运行时间检查可以在代理 Teams AI 没有提升时超时. 在启动 OpenCode 团队前以管理员身份重新启动应用程序 。"
"title": "推荐使用 Windows 管理员模式",
"description": "当 Agent Teams AI 未提升时OpenCode 运行时检查可能会超时。在启动 OpenCode 团队之前,使用以管理员身份运行重新启动应用。"
},
"webPreview": {
"title": "打开桌面应用程序以完整功能",
"description": "浏览器版本仍在开发中. 这里的项目行动、整合和现场状态更新可能有限。 使用桌面应用程序可靠地访问所有特性 。"
"title": "打开桌面应用获取完整功能",
"description": "浏览器版本仍在开发中。项目操作、集成和实时状态更新可能会受到限制。使用桌面应用可靠地访问所有功能。"
},
"updateBanner": {
"newVersionAvailable": "新版本可用",
"restartNow": "重新开始",
"viewDetails": "查看细节"
"restartNow": "立即重新启动",
"viewDetails": "查看详情"
}
}

View file

@ -1,3 +1,3 @@
{
"fallback": "出了点问题"
"fallback": "出了点问题"
}

View file

@ -2,62 +2,62 @@
"store": {
"actions": {
"addCustom": "添加自定义",
"openDashboard": "打开挂板",
"openDashboard": "打开控制台",
"refreshCatalog": "刷新目录"
},
"capabilities": {
"mcp": "磁共振:{{status}}",
"plugins": "插件: {{status}}",
"skills": "技能:{{status}}"
"mcp": "MCP{{status}}",
"plugins": "插件{{status}}",
"skills": "技能{{status}}"
},
"desktopOnly": "只有桌面应用程序可用 。",
"desktopOnly": "仅在桌面应用中可用。",
"provider": {
"checkingStatus": "正在检查提供者状态...",
"checkingStatus": "正在检查提供商状态…",
"connected": "已连接",
"loading": "正在装入...",
"loading": "加载中…",
"needsSetup": "需要设置",
"readyToConfigure": "准备配置",
"unsupported": "不支持"
},
"runtime": {
"checkingAvailabilityDescription": "扩展需要配置的运行时间来管理插件,MCP服务器,技能和供应商连接.",
"checkingAvailabilityTitle": "检查扩展运行时可用性",
"failedToStartDescription": "在运行时间通过启动健康检查之前,延长时间将被禁用。 打开Dash板修理或重新安装.",
"failedToStartTitle": "已找到配置的运行时间, 但启动失败",
"multimodelCapabilitiesDescription": "提供方的支持可因各节而异. 插件仅在运行时间明确宣布支持时显示 。",
"multimodelCapabilitiesTitle": "多模式运行时间能力",
"needsSignInDescription": "{{runtime}}被发现了{{version}},但插件安装被禁用,直到从Dashboard上签名.",
"checkingAvailabilityDescription": "扩展需要配置的运行时来管理插件、MCP 服务器、技能和提供商连接。",
"checkingAvailabilityTitle": "检查扩展运行时可用性",
"failedToStartDescription": "在运行时通过其启动运行状况检查之前,扩展将被禁用。打开控制台进行修复或重新安装。",
"failedToStartTitle": "已找到配置的运行时但启动失败",
"multimodelCapabilitiesDescription": "不同功能的提供商支持可能不同。仅当运行时明确声明支持时才会显示插件。",
"multimodelCapabilitiesTitle": "多模型运行时功能",
"needsSignInDescription": "找到了 {{runtime}}{{version}},但在您从控制台登录之前,插件安装将被禁用。",
"needsSignInTitle": "{{runtime}} 需要登录",
"notAvailableDescription": "扩展功能在运行时间安装之前被禁用. 打开Dash板安装并重试.",
"notAvailableTitle": "配置的运行时不可用",
"readyDescription": "插件可以从此页{{versionSuffix}}安装.",
"readyTitle": "{{runtime}}准备好",
"requiredForMutations": "安装或卸载扩展需要配置的运行时间. 从Dashboard上安装或修复."
"notAvailableDescription": "在安装运行时之前,扩展将被禁用。打开控制台进行安装并重试。",
"notAvailableTitle": "配置的运行时不可用",
"readyDescription": "可以从此页面安装插件 {{versionSuffix}}。",
"readyTitle": "{{runtime}}准备好",
"requiredForMutations": "安装或卸载扩展需要配置的运行时。从控制台安装或修复它。"
},
"sessionsRestartWarning": "运行会话在重新启动前不会接收扩展更改 。",
"sessionsRestartWarning": "正在运行的会话在重新启动之前不会获取扩展更改。",
"tabs": {
"apiKeys": {
"description": "在线服务的秘密密钥 。 在此添加它们, 这样插件, 服务器和集成可以连接和工作 。",
"label": "API 苏维埃社会主义共和国 键"
"description": "在线服务的密钥。将它们添加到此处,以便插件、服务器和集成可以连接和工作。",
"label": "API 密钥"
},
"mcpServers": {
"description": "连接到外部工具和应用程序 。 他们让运行时间读取数据或者做超过这个应用程序的行动.",
"label": "MCP 苏维埃社会主义共和国 服务器"
"description": "与外部工具和应用的连接。它们让运行时读取数据或执行此应用之外的操作。",
"label": "MCP 服务器"
},
"plugins": {
"description": "跑步时间加一点 在多模型模式中,当支持时,它们目前适用于Anthropic会话。 更广泛的提供者支助正在发展之中。",
"description": "运行时的小附加组件。在多模型模式下,它们当前适用于受支持的 Anthropic 会话。更广泛的提供商支持正在开发中。",
"label": "插件"
},
"skills": {
"description": "为普通工作准备的指令。 它们有助于运行时间更一致地处理可重复的任务.",
"description": "常见工作的现成说明。它们帮助运行时更一致地处理可重复的任务。",
"label": "技能"
}
},
"title": "扩展"
},
"pluginsPanel": {
"activeFilters": "{{count}}活动",
"browseByFit": "以适合方式浏览",
"activeFilters": "{{count}} 活跃",
"browseByFit": "按适合浏览",
"capabilities": "能力",
"categories": "类别",
"clearAllFilters": "清除所有过滤器",
@ -80,93 +80,93 @@
"plugins_other": "{{count}} 插件"
},
"empty": {
"description": "稍后检查新插件",
"filteredDescription": "尝试调整搜索或过滤标准",
"filteredTitle": "没有匹配过滤器的插件",
"description": "稍后回来查看新插件",
"filteredDescription": "尝试调整您的搜索或过滤条件",
"filteredTitle": "没有插件符合您的过滤器",
"title": "没有可用的插件"
},
"filterDescription": "按类别、或安装状态缩小目录。",
"installedOnly": "仅安装",
"providerSupportNotice": "目前只为Anthropic(克劳德)会议保证插件支持。 我们正在支持插件 跨所有代理。",
"resultsUpdateInstantly": "精炼过滤器时立即更新结果。",
"searchPlaceholder": "搜索插件...",
"selectedCount": "{{count}}选中",
"showing": "显示{{shown}}页:1{{total}}插件",
"filterDescription": "按类别、能或安装状态缩小目录范围。",
"installedOnly": "仅安装",
"providerSupportNotice": "目前仅保证 Anthropic (Claude) 会话的插件支持。我们正在努力支持所有智能体的插件。",
"resultsUpdateInstantly": "当您优化过滤器时,结果会立即更新。",
"searchPlaceholder": "搜索插件",
"selectedCount": "已选择 {{count}}",
"showing": "正在显示 {{shown}} / {{total}} 个插件",
"sort": {
"category": "类别",
"nameAsc": "名称 A-Z",
"nameDesc": "名字为Z - A级",
"popular": "大众"
"nameDesc": "名称 Z-A",
"popular": "受欢迎的"
},
"activeFilters_few": "{{count}}活动",
"activeFilters_many": "{{count}}活动",
"activeFilters_one": "{{count}}活动",
"activeFilters_other": "{{count}}活动",
"selectedCount_few": "{{count}}选中",
"selectedCount_many": "{{count}}选中",
"selectedCount_one": "{{count}}选中",
"selectedCount_other": "{{count}}选中"
"activeFilters_few": "{{count}} 活跃",
"activeFilters_many": "{{count}} 活跃",
"activeFilters_one": "{{count}} 活跃",
"activeFilters_other": "{{count}} 活跃",
"selectedCount_few": "已选择 {{count}}",
"selectedCount_many": "已选择 {{count}}",
"selectedCount_one": "已选择 {{count}}",
"selectedCount_other": "已选择 {{count}}"
},
"customMcp": {
"actions": {
"add": "添加",
"cancel": "取消",
"install": "安装",
"installing": "安装中..."
"installing": "正在安装…"
},
"description": "在没有目录的情况下手动添加服务器 。",
"description": "手动添加服务器,无需目录。",
"errors": {
"installFailed": "安装失败",
"invalidServerName": "无效的服务器名称 。 使用字母数字字符,破折号,下划线,点.",
"npmPackageRequired": "需要 npm 软件包名称",
"serverNameRequired": "需要服务器名称",
"serverUrlRequired": "需要服务器 URL"
"invalidServerName": "服务器名称无效。使用字母数字字符、破折号、下划线、点。",
"npmPackageRequired": "npm 包名是必需的",
"serverNameRequired": "服务器名称为必填项",
"serverUrlRequired": "服务器 URL 为必填项"
},
"fields": {
"environmentVariables": "环境变量",
"headers": "页眉",
"npmPackage": "npm 软件包",
"headers": "标头",
"npmPackage": "npm 包",
"scope": "范围",
"serverName": "服务器名称",
"serverUrl": "服务器 URL",
"serverUrl": "服务器地址",
"transport": "运输",
"transportType": "运输类型",
"versionOptional": "版本( 可选)"
"versionOptional": "版本(可选)"
},
"title": "添加自定义 MCP 服务器",
"transport": {
"httpSse": "ZXCVKEN0ZXCV / ZXCVTKEN1ZXCV",
"stdio": "Stdio (npm) (英语)."
"httpSse": "HTTP/SSE",
"stdio": "Stdio (npm)"
},
"placeholders": {
"headerName": "页眉 - Name",
"envVarName": "ENV_VAR_NAME 苏维埃社会主义共和国",
"headerName": "标头名称",
"envVarName": "ENV_VAR_NAME",
"serverName": "我的服务器",
"latest": "最新消息",
"latest": "最新",
"value": "价值",
"serverUrl": "https://api.example.com/mcp 苏维埃社会主义共和国"
"serverUrl": "https://api.example.com/mcp"
}
},
"mcpDetail": {
"auth": {
"remoteMayNeedHeaders": "远程 MCP服务器可能仍然需要自定义头或API密钥,即使登记册没有描述它们. 如果安装后连接失败, 请检查提供者 docs 。",
"required": "服务器需要认证"
"remoteMayNeedHeaders": "即使注册表没有描述,远程 MCP 服务器可能仍然需要自定义标头或 API 密钥。如果安装后连接失败,请检查提供商文档。",
"required": "服务器需要认证"
},
"diagnostics": {
"launchTarget": "发目标"
"launchTarget": "发目标"
},
"form": {
"autoFilled": "自动填充",
"environmentVariables": "环境变量",
"headers": "页眉",
"headers": "标头",
"scope": "范围",
"serverName": "服务器名称"
},
"install": {
"httpTransport": "HTTP: (英语).{{transport}}",
"manualSetupDescription": "此服务器需要手动设置 。 检查仓库以获取安装指令 。",
"httpTransport": "HTTP{{transport}}",
"manualSetupDescription": "该服务器需要手动设置。检查仓库以获取安装说明。",
"manualSetupRequired": "需要手动设置",
"npmPackage": "注:{{package}}",
"npmPackage": "npm: {{package}}",
"manage": "管理安装",
"install": "安装服务器"
},
@ -177,25 +177,25 @@
},
"metadata": {
"author": "作者",
"githubStars": "GitHub ",
"githubStars": "GitHub 星",
"hosting": "托管",
"installType": "安装类型",
"license": "许可证",
"published": "已出版",
"license": "执照",
"published": "已发表",
"source": "来源",
"updated": "更新",
"updated": "更新",
"version": "版本"
},
"scope": {
"local": "地",
"local": "地",
"project": "项目"
},
"tools": {
"title": "工具 ({{count}})",
"title_few": "工具 ({{count}})",
"title_many": "工具 ({{count}})",
"title_one": "工具 ({{count}})",
"title_other": "工具 ({{count}})"
"title": "工具{{count}}",
"title_few": "工具{{count}}",
"title_many": "工具{{count}}",
"title_one": "工具{{count}}",
"title_other": "工具{{count}}"
},
"placeholders": {
"serverName": "我的服务器"
@ -205,86 +205,86 @@
"actions": {
"cancel": "取消",
"createSkill": "创建技能",
"preparing": "准备...",
"reviewAndCreate": "审和创建",
"reviewAndSave": "查并保存",
"preparing": "正在准备…",
"reviewAndCreate": "审和创建",
"reviewAndSave": "并保存",
"saveSkill": "保存技能"
},
"advanced": {
"customDescription": "此技能使用自定义的标记下格式, 因此在此直接编辑 。",
"customTitle": "2. SKILL.md编辑器.",
"description": "大多数人可以跳过这个。 只有在您想要直接控制原始标记下文件时才打开 。",
"customDescription": "该技能使用自定义的 markdown 格式,所以直接在这里编辑。",
"customTitle": "2.SKILL.md 编辑器",
"description": "大多数人可以跳过这一点。仅当您想直接控制原始 Markdown 文件时才打开它。",
"hide": "隐藏高级编辑器",
"resetFromStructuredFields": "从结构化字段重置",
"show": "显示高级编辑器",
"title": "4.高级 SKILL.md 编辑器"
},
"basics": {
"description": "给这种技能一个明确的名字,选择谁可以使用它,并决定它应该住在哪里.",
"title": "1. 基本情况"
"description": "给这个技能一个清晰的名称,选择谁可以使用它,并决定它应该存在的位置。",
"title": "1. 基础知识"
},
"description": {
"create": "用简单的语言描述工作流程,审查将要创建的文件,然后保存它。",
"edit": "更新此技能, 审查由此产生的文件更改, 然后保存它 。"
"create": "用通俗易懂的语言描述工作流程,查看将创建的文件,然后保存。",
"edit": "更新此技能,查看生成的文件更改,然后保存。"
},
"extraFiles": {
"addedFiles": "添加的文件:",
"addedFiles": "添加的文件",
"assets": "资产",
"assetsDescription": "添加截图或捆绑的介质,前提是它们有助于解释工作流程.",
"description": "只有当支持文件、脚本或资产的技能确实需要时,才添加它们。",
"lockedForEdits": "根和文件夹已锁定用于编辑",
"optionalDescription": "添加将包含在审查中的启动文件,并与 `SKILL.md` 一起编写.",
"assetsDescription": "仅当屏幕截图或捆绑媒体有助于解释工作流程时才添加它们。",
"description": "仅当该技能确实需要时才添加支持文档、脚本或资产。",
"lockedForEdits": "根目录和文件夹已锁定以进行编辑",
"optionalDescription": "添加将包含在评论中并与“SKILL.md”一起编写的入门文件。",
"optionalTitle": "可选文件",
"references": "参考资料",
"referencesDescription": "添加支持文件、 链接或运行时可以查看的例。",
"references": "参考",
"referencesDescription": "添加运行时可以查看的支持文档、链接或示例。",
"scripts": "脚本",
"scriptsDescription": "添加帮助命令或设置注释. 在分享这种技能之前仔细审查。",
"scriptsDescription": "添加帮助命令或设置注释。在分享此技能之前请仔细查看。",
"title": "3. 额外文件"
},
"fields": {
"compatibility": "兼容性",
"description": "说明",
"description": "描述",
"folderName": "文件夹名称",
"folderNameHint": "我们从技能名称中自动提出这个建议,所以审查立即进行。",
"invocation": "应如何使用",
"license": "许可证",
"folderNameHint": "我们会根据技能名称自动建议此操作,以便立即进行审核。",
"invocation": "应如何使用",
"license": "执照",
"name": "技能名称",
"notes": "额外纸条或护栏",
"root": "在哪里储存",
"scope": "谁能用呢?",
"steps": "接下来的主要步骤",
"whenToUse": "什么时候才能找到这个"
"notes": "额外注释或护栏",
"root": "存放在哪里",
"scope": "谁可以使用它",
"steps": "需要遵循的主要步骤",
"whenToUse": "何时达到这个目标"
},
"instructions": {
"description": "这些章节为您生成技能文件, 因此您不需要编辑降标, 除非您想要 。",
"locked": "结构化字段被锁定,因为您切换到下面的手动`SKILL.md`编辑.",
"title": "2. 指示"
"description": "这些部分会为您生成技能文件,因此除非您愿意,否则不需要编辑 Markdown。",
"locked": "结构化字段被锁定因为您切换到下面的手动“SKILL.md”编辑。",
"title": "2. 使用说明"
},
"invocation": {
"auto": "可自动使用",
"manualOnly": "只有你要求的时候"
"auto": "可自动使用",
"manualOnly": "只有你要求的时候"
},
"placeholders": {
"description": "这种技巧能帮助什么",
"name": "写简洁的技巧名称",
"notes": "示例:列出缺失的测试,回归和风险假设.",
"steps": "1. 检查有关档案。\n2. 先解释主要风险。\n3. 提出最安全的解决办法。",
"whenToUse": "示例: 当任务为代码检讨或错误分类请求时使用此功能 。",
"license": "MIT 苏维埃社会主义共和国",
"compatibility": "圆形码,光标"
"description": "这项技能有什么帮助",
"name": "写出简洁的技能名称",
"notes": "示例:指出缺失的测试、回归和风险假设。",
"steps": "1.检查相关文件。 2、先说明主要风险。 3.提出最安全的修复建议。",
"whenToUse": "示例:当任务是代码审核或错误分类请求时使用此选项。",
"license": "麻省理工学院",
"compatibility": "Claude Code光标"
},
"review": {
"creating": "创技能",
"hint": "首先审查文件更改,然后在下一步确认保存 。",
"creating": "创技能",
"hint": "首先查看文件更改,然后在下一步中确认保存。",
"saving": "保存此技能"
},
"root": {
"codexOnly": "- 只有代码",
"codexOnly": "- 仅 Codex",
"shared": "- 共享"
},
"scope": {
"project": "项目:{{project}}",
"projectUnavailable": "无法执行的项目",
"project": "项目{{project}}",
"projectUnavailable": "项目不可用",
"user": "用户"
},
"title": {
@ -297,10 +297,10 @@
"cancel": "取消",
"delete": "删除",
"deleteSkill": "删除技能",
"deleting": "正在删除...",
"deleting": "正在删除",
"editSkill": "编辑技能",
"openFolder": "打开文件夹",
"openSkillFile": "打开 SKILL.md",
"openSkillFile": "打开技能.md",
"retry": "重试"
},
"badges": {
@ -308,75 +308,75 @@
"autoUse": "自动使用",
"hasScripts": "有脚本",
"manualUse": "手动使用",
"references": "参考资料",
"storedIn": "存放于{{root}}"
"references": "参考",
"storedIn": "存储在 {{root}} 中"
},
"deleteDialog": {
"description": "删除此技能并移动到回收站?",
"descriptionWithName": "删除\"{{name}}\"并将其移动到垃圾堆? 如果需要, 您可以稍后从回收站恢复它 。",
"title": "删除技能?"
"description": "删除该技能并将其移至垃圾箱?",
"descriptionWithName": "删除“{{name}}”并将其移至垃圾箱?如果需要,您可以稍后从“垃圾箱”中恢复它。",
"title": "删除技能"
},
"descriptionFallback": "检查发现了技能元数据和原始指令.",
"descriptionFallback": "检查发现的技能元数据和原始指令。",
"errors": {
"deleteFailed": "删除技能失败",
"loadFailed": "无法装入此技能 。"
"loadFailed": "无法加载该技能。"
},
"files": {
"advancedDetails": "高级文件细节",
"advancedDetails": "高级文件详细信息",
"assets": "资产",
"references": "参考资料",
"references": "参考",
"scripts": "脚本",
"storedAt": "存于"
"storedAt": "存于"
},
"includes": {
"assets": "资产",
"instructionsOnly": "只是技巧指导",
"references": "参考文献",
"instructionsOnly": "只是技能说明",
"references": "参考",
"scripts": "脚本"
},
"invocation": {
"auto": "匹配任务时自动运行。",
"manualOnly": "只有在你明确要求的时候才能跑"
"manualOnly": "仅当您明确要求时才运行。"
},
"issues": {
"bundledScripts": "此技能包括捆绑的脚本",
"reviewCarefully": "在使用之前仔细审查一下这个技能"
"bundledScripts": "该技能包括捆绑脚本",
"reviewCarefully": "使用此技能之前请仔细查看"
},
"loading": "正在装入技能细节...",
"loading": "正在加载技能详细信息…",
"scope": {
"personal": "你的个人",
"projectOnly": "只有这个项目"
"personal": "你的个人能",
"projectOnly": "仅此项目"
},
"summary": {
"howUsed": "如何使用",
"included": "怎么会这样",
"whoCanUse": "谁能用呢?"
"included": "附带什么",
"whoCanUse": "谁可以使用它"
},
"titleFallback": "技能细节"
"titleFallback": "技能详情"
},
"skillsPanel": {
"actions": {
"createSkill": "创建技能",
"import": "导入"
"import": "进口"
},
"badges": {
"assets": "资产",
"hasScripts": "有脚本",
"needsAttention": "需要注",
"references": "参考资料",
"storedIn": "存放于{{root}}"
"needsAttention": "需要",
"references": "参考",
"storedIn": "存储在 {{root}} 中"
},
"configuredRuntime": "配置的运行时",
"configuredRuntime": "配置的运行时",
"counts": {
"codexOnly": "{{count}} 苏维埃社会主义共和国 仅编码",
"codexOnly": "{{count}} 仅限 Codex",
"personal": "{{count}} 个人",
"project": "{{count}} 项目",
"shared": "共享 {{count}}",
"total": "{{count}}计",
"codexOnly_few": "{{count}} 苏维埃社会主义共和国 仅编码",
"codexOnly_many": "{{count}} 苏维埃社会主义共和国 仅编码",
"codexOnly_one": "{{count}} 苏维埃社会主义共和国 仅编码",
"codexOnly_other": "{{count}} 苏维埃社会主义共和国 仅编码",
"shared": "{{count}} 共享",
"total": "{{count}}计",
"codexOnly_few": "{{count}} 仅限 Codex",
"codexOnly_many": "{{count}} 仅限 Codex",
"codexOnly_one": "{{count}} 仅限 Codex",
"codexOnly_other": "{{count}} 仅限 Codex",
"personal_few": "{{count}} 个人",
"personal_many": "{{count}} 个人",
"personal_one": "{{count}} 个人",
@ -385,269 +385,269 @@
"project_many": "{{count}} 项目",
"project_one": "{{count}} 项目",
"project_other": "{{count}} 项目",
"shared_few": "共享 {{count}}",
"shared_many": "共享 {{count}}",
"shared_one": "共享 {{count}}",
"shared_other": "共享 {{count}}",
"total_few": "{{count}}计",
"total_many": "{{count}}计",
"total_one": "{{count}}计",
"total_other": "{{count}}计"
"shared_few": "{{count}} 共享",
"shared_many": "{{count}} 共享",
"shared_one": "{{count}} 共享",
"shared_other": "{{count}} 共享",
"total_few": "{{count}}计",
"total_many": "{{count}}计",
"total_one": "{{count}}计",
"total_other": "{{count}}计"
},
"empty": {
"noMatches": "没有与搜索相匹配的技能",
"noMatchesDescription": "尝试不同的搜索术语或切换过滤器 。",
"noMatches": "没有符合您搜索条件的技能",
"noMatchesDescription": "尝试不同的搜索词或切换过滤器。",
"noSkills": "还没有技能",
"noSkillsDescription": "创建您的第一个教授可重复工作流程的技能, 或者导入您已经使用的工作流程 。"
"noSkillsDescription": "创建您的第一项技能来教授可重复的工作流程,或导入您已经使用的技能。"
},
"filters": {
"all": "所有技能",
"codexOnly": "仅编码",
"codexOnly": "仅 Codex",
"hasScripts": "有脚本",
"needsAttention": "需要注",
"personal": "个人",
"needsAttention": "需要",
"personal": "个人",
"project": "项目",
"shared": "共享"
},
"hero": {
"codexAvailable": "使用 `.codex` 当一个技能应该只保留 Codex 时.",
"codexUnavailable": "现有的`.codex`技能在这里仍然可以编辑,但新的 Codex 唯一技能需要启用 Codex 运行时间.",
"description": "技能是可重复使用的指示,有助于运行时间更一致地处理同样的任务.",
"guidance": "将个人技能用于所有你想要的习惯。 将工程技能用于仅在一个代码库内讲得通的工作流程.",
"personalContext": "你现在只看到个人技能",
"projectContext": "您看到的是{{project}}的技能 加上您的个人技能。",
"codexAvailable": "当技能应仅适用于 Codex 时,请使用“.codex”。",
"codexUnavailable": "现有的“.codex”技能在此处保持可编辑状态但新的仅限 Codex 的技能需要启用 Codex 运行时。",
"description": "技能是可重用的指令,可帮助运行时更一致地处理同类任务。",
"guidance": "使用个人技能来养成你想要的随处习惯。将项目技能用于仅在一个代码库中有意义的工作流程。",
"personalContext": "您现在只能看到您的个人技能。",
"projectContext": "您正在看到 {{project}} 的技能以及您的个人技能。",
"title": "教授可重复的工作"
},
"invocation": {
"auto": "适合时自动运行",
"manualOnly": "只当您明确要求时运行"
"manualOnly": "仅在您明确要求时运行"
},
"loading": {
"loading": "正在装入技能...",
"refreshing": "刷新技能中..."
"loading": "技能加载中…",
"refreshing": "技能刷新…"
},
"runtimeAudience": "`.claude`,`.cursor`和`.agents`的共享技能可以提供给{{audience}}. 存储在`.codex`中的技能在 Codex 支持可用时只保留 Codex.",
"runtimeAudience": "{{audience}} 可以使用“.claude”、“.cursor”和“.agents”中的共享技能。当 Codex 支持可用时,存储在“.codex”中的技能仅适用于 Codex。",
"scope": {
"project": "这个项目",
"user": "个人"
"project": "项目",
"user": "个人"
},
"searchPlaceholder": "以技能名称或它能帮助什么搜索...",
"searchPlaceholder": "按技能名称或它的帮助进行搜索…",
"sections": {
"personal": {
"description": "随处可见你想要的卫生用品和指示",
"description": "您想要的习惯和说明随处可见。",
"title": "个人技能"
},
"project": {
"description": "只有这个密码库才有意义的工作流程。",
"description": "仅对此代码库有意义的工作流程。",
"title": "项目技能"
}
},
"sort": {
"label": "排序技",
"name": "",
"recent": "近期"
"label": "排序技",
"name": "名",
"recent": "最近的"
},
"status": {
"hasScripts": "包括脚本, 所以仔细审查",
"needsAttention": "依赖它之前需要注意",
"hasScripts": "包含脚本,请仔细查看",
"needsAttention": "依赖它之前需要注意",
"ready": "准备使用"
},
"success": {
"created": "技能创建成功。",
"imported": "成功导入技能 。",
"imported": "技能导入成功。",
"saved": "技能保存成功。"
}
},
"pluginDetail": {
"unknown": "未知",
"unknown": "未知",
"metadata": {
"author": "作者",
"category": "类别",
"source": "来源",
"version": "版本",
"capabilities": "能力",
"installs": "安装"
"installs": "安装"
},
"scope": {
"label": "范围:",
"label": "范围",
"options": {
"user": "用户( 全球)",
"project": "项目(分摊)",
"local": "当地( 被忽略)"
"user": "用户(全球)",
"project": "项目(共享)",
"local": "本地gitignored"
}
},
"links": {
"homepage": "主页",
"contact": "联系人"
"contact": "接触"
},
"readme": {
"loading": "正在装入 ZXCVTKEN0ZXCV...",
"empty": "没有ZXCVKEN0ZXCV。"
"loading": "正在加载自述文件…",
"empty": "没有可用的自述文件。"
}
},
"skillImport": {
"title": "导入技能",
"description": "选择一个已有的技能文件夹, 审查要复制的内容, 然后导入到您支持的技能位置 。",
"description": "选择一个现有技能文件夹,查看将复制的内容,然后将其导入到您支持的技能位置之一。",
"steps": {
"chooseFolder": {
"title": "1.选择技能文件夹",
"description": "此文件夹应该已经包含 `SKILL.md`, `Skill.md`, 或 `skill.md` 文件 。"
"description": "该文件夹应该已包含“SKILL.md”、“Skill.md”或“skill.md”文件。"
},
"location": {
"title": "2. 决定属于何处",
"description": "个人技能随处可见。 项目技能只为一个代码库出现."
"title": "2. 决定它所属的地方",
"description": "个人技能无处不在。项目技能仅针对一个代码库显示。"
}
},
"fields": {
"sourceFolder": "源文件夹",
"destinationFolderName": "目标文件夹名称",
"audience": "谁能用呢?",
"storage": "在哪里储存"
"audience": "谁可以使用它",
"storage": "存放在哪里"
},
"placeholders": {
"defaultFolderName": "源文件夹名称的默认值"
"defaultFolderName": "默认为源文件夹名称"
},
"actions": {
"browse": "浏览",
"cancel": "取消",
"preparing": "准备...",
"reviewAndImport": "审和导入",
"preparing": "正在准备…",
"reviewAndImport": "审和导入",
"importSkill": "导入技能",
"backToImport": "返回导入"
},
"scope": {
"user": "用户",
"project": "项目:{{project}}",
"projectUnavailable": "无法执行的项目"
"project": "项目{{project}}",
"projectUnavailable": "项目不可用"
},
"rootSuffix": {
"codexOnly": "- 只有代码",
"codexOnly": "- 仅 Codex",
"shared": "- 共享"
},
"reviewHint": "首先审查复制的文件,然后在下一步确认导入.",
"reviewHint": "首先检查复制的文件,然后在下一步中确认导入。",
"reviewLabel": "导入此技能",
"errors": {
"missingSkillFile": "此文件夹看起来还不是技能 。 它需要一个SKILL.md,Skill.md,或技能Md文件.",
"symbolicLinks": "此文件夹包含符号链接 。 导入真实文件而不是链接 。",
"tooManyFiles": "此技能文件夹太大, 无法同时导入 。 删除额外文件并再次尝试 。",
"tooLarge": "此技能文件夹太大, 无法安全导入 。 调整大资产,再试一次。",
"invalidFolderName": "用字母、数字、点、破折号或下划线选择一个更简单的目文件夹名称。",
"mustBeDirectory": "选择要导入的文件夹, 而不是一个文件 。",
"reviewFailed": "审查导入更改失败",
"importFailed": "导入技能失败"
"missingSkillFile": "这个文件夹看起来还不像技能。它需要 SKILL.md、Skill.md 或 skill.md 文件。",
"symbolicLinks": "该文件夹包含符号链接。导入真实文件而不是链接。",
"tooManyFiles": "该技能文件夹太大,无法一次导入。删除多余的文件并重试。",
"tooLarge": "该技能文件夹太大,无法安全导入。修剪大量资产并重试。",
"invalidFolderName": "使用字母、数字、点、破折号或下划线选择一个更简单的目文件夹名称。",
"mustBeDirectory": "选择要导入的文件夹,而不是单个文件。",
"reviewFailed": "无法审核导入更改",
"importFailed": "技能导入失败"
}
},
"mcpPanel": {
"sort": {
"nameAsc": "名称 A-Z",
"nameDesc": "名称 Z A",
"nameAsc": "名称 AZ",
"nameDesc": "名称 ZA",
"toolsDesc": "大多数工具"
},
"health": {
"title": "MCP 苏维埃社会主义共和国 健康状况",
"checkingViaRuntime": "通过{{runtime}}检查已安装的ZXVTOKEN0ZXCV服务器.",
"lastChecked": "上次选中的 {{time}}",
"description": "从此页面运行诊断,以验证已安装的MCP连接.",
"checking": "正在检查...",
"title": "MCP 健康状况",
"checkingViaRuntime": "通过 {{runtime}} 检查已安装的 MCP 服务器…",
"lastChecked": "最后检查 {{time}}",
"description": "从此页面运行诊断以验证已安装的 MCP 连接。",
"checking": "检查…",
"checkStatus": "检查状态"
},
"diagnostics": {
"title": "运行时 MCP 诊断",
"title": "运行时 MCP 诊断",
"serversCount": "{{count}} 服务器",
"serversCount_one": "{{count}} 服务器",
"serversCount_other": "{{count}} 服务器",
"waiting": "等待诊断结果...",
"waiting": "等待诊断结果",
"disableReasons": {
"checkingRuntimeStatus": "正在检查运行时间状态...",
"checkingRuntimeAvailability": "正在检查运行时间可用性...",
"runtimeFailedToStart": "已找到配置的运行时间, 但启动失败 。 打开Dash板进行修理或重新安装.",
"runtimeRequired": "需要配置的运行时间 。 从Dashboard上安装或修复."
"checkingRuntimeStatus": "正在检查运行时状态…",
"checkingRuntimeAvailability": "检查运行时可用性…",
"runtimeFailedToStart": "已找到配置的运行时,但启动失败。打开控制台进行修复或重新安装。",
"runtimeRequired": "需要配置的运行时。从控制台安装或修复它。"
},
"serversCount_few": "{{count}} 服务器",
"serversCount_many": "{{count}} 服务器"
},
"searchPlaceholder": "搜索 MCP 服务器...",
"searchPlaceholder": "搜索 MCP 服务器",
"runtime": {
"notAvailable": "{{runtime}}不详",
"notAvailable": "{{runtime}} 不可用",
"notInstalled": "{{runtime}} 未安装",
"requiredDescription": "MCP 健康检查要求{{runtime}}。去Dashboard安装或修复它。"
"requiredDescription": "MCP 运行状况检查需要 {{runtime}}。转至控制台进行安装或修复。"
},
"empty": {
"searchTitle": "未找到服务器",
"title": "没有可用的 MCP 服务器",
"searchDescription": "尝试不同的搜索词",
"description": "稍后检查新服务器"
"searchDescription": "尝试不同的搜索词",
"description": "稍后回来查看新服务器"
},
"loadMore": "装入更多"
"loadMore": "加载更多"
},
"apiKeys": {
"description": "安全存储 API 键,用于安装 MCP 服务器时自动填充.",
"description": "安全存储 API 密钥,以便在安装 MCP 服务器时自动填充。",
"storage": {
"osKeychain": "密钥通过{{backend}}加密,并以限制文件权限存储(只有所有者).",
"localEncryption": "OS 密钥链不可用 - 密钥在本地用 AES-256加密. 为了加强保护,安装密钥环服务(gnome-keyring,kwallet)."
"osKeychain": "密钥通过 {{backend}} 加密并以受限文件权限存储(仅限所有者)。",
"localEncryption": "操作系统钥匙串不可用 - 密钥使用 AES-256 在本地加密。为了获得更强的保护请安装密钥环服务gnome-keyring、kwallet"
},
"actions": {
"add": "添加 API 密钥",
"addFirst": "添加您的第一个密钥",
"addFirst": "添加您的第一把钥匙",
"edit": "编辑",
"copied": "复制!",
"copyEnvVarName": "复制环境变量名",
"copied": "复制",
"copyEnvVarName": "复制环境变量名",
"confirmDelete": "再次点击确认",
"delete": "删除"
},
"empty": {
"title": "没有保存 ZXCVKEN0ZXCV 密钥",
"description": "在安装 MCP 服务器时将密钥添加到自动填充环境变量中."
"title": "未保存 API 密钥",
"description": "安装 MCP 服务器时添加密钥以自动填充环境变量。"
},
"form": {
"addTitle": "添加 API 密钥",
"editTitle": "编辑 API 密钥",
"addDescription": "在MCP服务器安装中存储用于自动填充的API密钥.",
"editDescription": "更新关键细节 您必须重新输入值 。",
"keychainUnavailable": "OS 键链不可用 - 用 AES-256 本地加密的键. 为OS级保护安装 gnome- keyring 。",
"name": "",
"namePlaceholder": "例如,OpenAI生产",
"addDescription": "存储 API 密钥以便在 MCP 服务器安装中自动填充。",
"editDescription": "更新关键细节。您必须重新输入值。",
"keychainUnavailable": "操作系统钥匙串不可用 - 使用 AES-256 在本地加密的密钥。安装 gnome-keyring 以实现操作系统级别的保护。",
"name": "名",
"namePlaceholder": "例如 OpenAI 制作",
"environmentVariableName": "环境变量名称",
"envVarPlaceholder": "例如,OPENAI_API_KEY",
"value": "值",
"reenterValue": "重新输入密钥值",
"valuePlaceholder": "嘘...",
"envVarPlaceholder": "例如 OPENAI_API_KEY",
"value": "值",
"reenterValue": "重新输入值",
"valuePlaceholder": "sk-…",
"scope": "范围",
"userScopeLabel": "用户( 全球)",
"projectScopeLabel": "项目:{{project}}",
"projectUnavailable": "无法执行的项目",
"boundTo": "界于{{path}}",
"userScopeLabel": "用户(全球)",
"projectScopeLabel": "项目{{project}}",
"projectUnavailable": "项目不可用",
"boundTo": "绑定到 {{path}}",
"cancel": "取消",
"saving": "正在保存...",
"saving": "保存…",
"update": "更新",
"save": "保存",
"errors": {
"invalidEnvVarFormat": "用字母,数字,下划线 必须以字母或下划线开头 。",
"nameRequired": "需要名称",
"envVarRequired": "需要环境变量名称",
"invalidEnvVar": "无效的环境变量名称",
"valueRequired": "需要关键值",
"projectScopeRequiresProject": "工程范围 API 密钥需要运行中的项目",
"invalidEnvVarFormat": "使用字母、数字、下划线。必须以字母或下划线开头。",
"nameRequired": "姓名为必填项",
"envVarRequired": "环境变量名称为必填项",
"invalidEnvVar": "环境变量名称无效",
"valueRequired": "键值必填",
"projectScopeRequiresProject": "项目范围的 API 密钥需要一个活动项目",
"saveFailed": "保存失败"
}
}
},
"skillReview": {
"title": "审查技能变化",
"description": "{{reviewLabel}}先预览文件系统更改. 在下文确认之前,没有文字。",
"title": "查看技能变更",
"description": "{{reviewLabel}} 首先预览文件系统更改。在您确认以下内容之前,不会写入任何内容。",
"noPreview": "没有可用的预览。",
"confirmPromptPrefix": "查看下面的 diff, 然后使用",
"confirmPromptSuffix": "应用这些改。",
"confirmPromptPrefix": "查看下面的差异,然后使用",
"confirmPromptSuffix": "应用这些改。",
"noChanges": "尚未检测到文件更改。",
"binaryBadge": "二进制",
"binaryPreviewHidden": "未显示二进制文件预览 。 文件将被复制为-is 。",
"binaryPreviewHidden": "不显示二进制文件预览。文件将按原样复制。",
"summary": {
"fileChanges": "{{count}} 文件更改",
"fileChanges_one": "{{count}} 文件更改",
"fileChanges_other": "{{count}} 文件更改",
"new": "{{count}}",
"updated": "更新 {{count}}",
"removed": "{{count}}删除",
"new": "{{count}} 新",
"updated": "{{count}} 已更新",
"removed": "{{count}}删除",
"binary": "{{count}} 二进制",
"fileChanges_few": "{{count}} 文件更改",
"fileChanges_many": "{{count}} 文件更改"
@ -657,32 +657,32 @@
"toolsCount": "{{count}} 工具",
"toolsCount_one": "{{count}} 工具",
"toolsCount_other": "{{count}} 工具",
"envCount": "{{count}}内存",
"envCount_one": "{{count}} 掩体",
"envCount_other": "{{count}}内存",
"auth": "自动",
"byAuthor": "由{{author}}公司制作",
"envCount": "{{count}} 环境",
"envCount_one": "{{count}} 环境",
"envCount_other": "{{count}} 环境",
"auth": "授权",
"byAuthor": "通过 {{author}}",
"hosting": {
"remote": "远程",
"local": "地",
"both": "两"
"remote": "偏僻的",
"local": "地",
"both": "两个都"
},
"toolsCount_few": "{{count}} 工具",
"toolsCount_many": "{{count}} 工具",
"envCount_few": "{{count}}内存",
"envCount_many": "{{count}}内存",
"envCount_few": "{{count}} 环境",
"envCount_many": "{{count}} 环境",
"repository": "仓库",
"website": "网站"
},
"installButton": {
"installing": "安装中...",
"removing": "删除...",
"done": "写好了",
"installing": "正在安装…",
"removing": "正在删除…",
"done": "完毕",
"retry": "重试",
"uninstall": "卸载",
"install": "安装"
},
"pluginCard": {
"official": "官方"
"official": "官方"
}
}

View file

@ -1,31 +1,31 @@
{
"cost": {
"breakdownTitle": "成本细目(每100个令牌)",
"cacheRead": "缓存读",
"cacheWrite": "快取写入",
"cost": "费用",
"input": "入",
"noCommits": "无承诺",
"noLinesChanged": "无行更改",
"output": "出",
"parent": "父母:{{cost}}",
"parentCost": "父母费用",
"perCommit": "提交",
"perCommitFormula": "{{count}}总费用",
"perCommitFormula_few": "{{count}}总费用",
"perCommitFormula_many": "{{count}}总费用",
"perCommitFormula_one": "{{count}}总费用",
"perCommitFormula_other": "{{count}}总费用",
"perLineChanged": "每行变化",
"perLineFormula": "{{count}}项目",
"perLineFormula_few": "{{count}}线路",
"perLineFormula_many": "{{count}}线路",
"perLineFormula_one": "{{count}}项目",
"perLineFormula_other": "{{count}}线路",
"subagent": "副剂: {{cost}}",
"subagentCost": "亚剂费用",
"breakdownTitle": "成本明细(每 100 万个 Token",
"cacheRead": "缓存",
"cacheWrite": "缓存写入",
"cost": "成本",
"input": "入",
"noCommits": "没有提交",
"noLinesChanged": "没有改变行",
"output": "出",
"parent": "父级:{{cost}}",
"parentCost": "父级成本",
"perCommit": "每次提交",
"perCommitFormula": "总成本 ÷ {{count}} 提交",
"perCommitFormula_few": "总成本 ÷ {{count}} 提交",
"perCommitFormula_many": "总成本 ÷ {{count}} 提交",
"perCommitFormula_one": "总成本 ÷ {{count}} 提交",
"perCommitFormula_other": "总成本 ÷ {{count}} 提交",
"perLineChanged": "每行更改",
"perLineFormula": "总成本 ÷ {{count}} 行",
"perLineFormula_few": "总成本 ÷ {{count}} 行",
"perLineFormula_many": "总成本 ÷ {{count}} 行",
"perLineFormula_one": "总成本 ÷ {{count}} 行",
"perLineFormula_other": "总成本 ÷ {{count}} 行",
"subagent": "子智能体:{{cost}}",
"subagentCost": "子智能体成本",
"title": "成本分析",
"total": "计"
"total": "计"
},
"insights": {
"agent": "代理人",
@ -33,185 +33,185 @@
"agent_many": "代理人",
"agent_one": "代理人",
"agent_other": "代理人",
"agentTree": "代理树 ({{count}}) 代理树 ({{unit}})",
"background": "(背景情况)",
"agentTree": "智能体树 ({{count}} {{unit}})",
"background": "(后台)",
"bashCommands": "Bash 命令",
"outOfScopeFindings": "范围外调查结果({{count}})",
"outOfScopeFindings": "超出范围的调查结果 ({{count}})",
"questionsAsked": "提出的问题 ({{count}})",
"repeated": "重复",
"skillsInvoked": "被举报技能({{count}})",
"skillsInvoked": "调用的技能 ({{count}})",
"taskDispatches": "任务调度 ({{count}})",
"tasksCreated": "创建任务( {{count}})",
"tasksCreated": "创建任务 ({{count}})",
"teamMode": "团队模式",
"teams": "团队:{{teams}}",
"title": "会话透视",
"total": "计",
"unique": "独一无二",
"skillsInvoked_few": "被举报技能({{count}})",
"skillsInvoked_many": "被举报技能({{count}})",
"skillsInvoked_one": "被举报技能({{count}})",
"skillsInvoked_other": "被举报技能({{count}})",
"teams": "队伍:{{teams}}",
"title": "会话见解",
"total": "计",
"unique": "唯一",
"skillsInvoked_few": "调用的技能 ({{count}})",
"skillsInvoked_many": "调用的技能 ({{count}})",
"skillsInvoked_one": "调用的技能 ({{count}})",
"skillsInvoked_other": "调用的技能 ({{count}})",
"taskDispatches_few": "任务调度 ({{count}})",
"taskDispatches_many": "任务调度 ({{count}})",
"taskDispatches_one": "任务调度 ({{count}})",
"taskDispatches_other": "任务调度 ({{count}})",
"tasksCreated_few": "创建任务( {{count}})",
"tasksCreated_many": "创建任务( {{count}})",
"tasksCreated_one": "创建任务( {{count}})",
"tasksCreated_other": "创建任务( {{count}})",
"tasksCreated_few": "创建任务 ({{count}})",
"tasksCreated_many": "创建任务 ({{count}})",
"tasksCreated_one": "创建任务 ({{count}})",
"tasksCreated_other": "创建任务 ({{count}})",
"questionsAsked_few": "提出的问题 ({{count}})",
"questionsAsked_many": "提出的问题 ({{count}})",
"questionsAsked_one": "提出的问题 ({{count}})",
"questionsAsked_other": "提出的问题 ({{count}})",
"agentTree_few": "代理树 ({{count}}) 代理树 ({{unit}})",
"agentTree_many": "代理树 ({{count}}) 代理树 ({{unit}})",
"agentTree_one": "代理树 ({{count}}) 代理树 ({{unit}})",
"agentTree_other": "代理树 ({{count}}) 代理树 ({{unit}})",
"outOfScopeFindings_few": "范围外调查结果({{count}})",
"outOfScopeFindings_many": "范围外调查结果({{count}})",
"outOfScopeFindings_one": "范围外调查结果({{count}})",
"outOfScopeFindings_other": "范围外调查结果({{count}})",
"keyTakeaways": "关键外卖"
"agentTree_few": "智能体树 ({{count}} {{unit}})",
"agentTree_many": "智能体树 ({{count}} {{unit}})",
"agentTree_one": "智能体树 ({{count}} {{unit}})",
"agentTree_other": "智能体树 ({{count}} {{unit}})",
"outOfScopeFindings_few": "超出范围的调查结果 ({{count}})",
"outOfScopeFindings_many": "超出范围的调查结果 ({{count}})",
"outOfScopeFindings_one": "超出范围的调查结果 ({{count}})",
"outOfScopeFindings_other": "超出范围的调查结果 ({{count}})",
"keyTakeaways": "要点"
},
"quality": {
"chars": "字符",
"corrections": "惩戒",
"corrections": "修正",
"failed": "失败",
"fileReadRedundancy": "文件读取冗余",
"firstMessage": "第一消息",
"firstRun": "第一运行",
"firstMessage": "第一消息",
"firstRun": "第一运行",
"frictionRate": "摩擦率",
"lastRun": "次运行",
"messagesBeforeWork": "工作前的信件",
"lastRun": "最后一次运行",
"messagesBeforeWork": "开始工作前消息",
"passed": "通过",
"promptQuality": "提示质量",
"promptQuality": "提示质量",
"readsPerUniqueFile": "读取/唯一文件",
"snapshot": "简介",
"snapshot": "快照",
"snapshot_few": "快照",
"snapshot_many": "快照",
"snapshot_one": "简介",
"snapshot_one": "快照",
"snapshot_other": "快照",
"startupOverhead": "启动间接费用",
"testProgression": "测试进",
"startupOverhead": "启动开销",
"testProgression": "测试进",
"title": "质量信号",
"tokensBeforeWork": "工作前托肯斯语Name",
"totalReads": "读数共计",
"uniqueFiles": "独一无二的文件",
"userMessages": "用户信件",
"percentOfTotal": "占总数的百分比"
"tokensBeforeWork": "工作前的 Token",
"totalReads": "取次数",
"uniqueFiles": "唯一文件",
"userMessages": "用户消息",
"percentOfTotal": "占总数的%"
},
"tokens": {
"apiCalls": "API 苏维埃社会主义共和国 电话",
"apiCalls": "API 调用",
"cacheCreate": "缓存创建",
"cacheEfficiency": "缓存效率",
"cacheRead": "缓存读",
"cacheReadPct": "快取读取%",
"cacheRead": "缓存",
"cacheReadPct": "缓存读取率",
"coldStart": "冷启动",
"cost": "费用",
"input": "入",
"model": "",
"no": "没有",
"output": "出",
"readWriteRatio": "R/W比率",
"title": "用",
"total": "计",
"yes": ""
"cost": "成本",
"input": "入",
"model": "型",
"no": "",
"output": "出",
"readWriteRatio": "读/写比",
"title": "Token 使用",
"total": "计",
"yes": ""
},
"subagents": {
"title": "副剂",
"title": "子智能体",
"metrics": {
"count": "数",
"totalTokens": "共计",
"totalDuration": "期间共计",
"totalCost": "费用共计"
"count": "",
"totalTokens": "Token 总数",
"totalDuration": "总持续时间",
"totalCost": "总成本"
},
"table": {
"description": "说明",
"description": "描述",
"type": "类型",
"tokens": "",
"duration": "会期",
"cost": "费用"
"tokens": "Token",
"duration": "持续时间",
"cost": "成本"
}
},
"overview": {
"title": "概",
"yes": "",
"no": "没有",
"title": "概",
"yes": "",
"no": "",
"metrics": {
"duration": "会期",
"messages": "信件",
"contextUsage": "背景使用情况",
"duration": "持续时间",
"messages": "消息",
"contextUsage": "上下文使用",
"compactions": "压缩",
"branch": "",
"subagents": "副剂",
"branch": "分支",
"subagents": "子智能体",
"project": "项目",
"sessionId": "会话编号"
"sessionId": "会话 ID"
}
},
"timeline": {
"title": "时间线和活动( A)",
"title": "时间线与活动",
"idleAnalysis": "空闲分析",
"metrics": {
"idleGaps": "空闲差距",
"totalIdle": "闲置共计",
"activeTime": "活时间",
"idlePercent": "闲置%"
"idleGaps": "空闲间隙",
"totalIdle": "总空闲时间",
"activeTime": "活时间",
"idlePercent": "空闲率"
},
"modelSwitches": "型号开关({{count}})",
"modelSwitches_one": "型号开关({{count}})",
"modelSwitches_other": "型号开关({{count}})",
"messageNumber": "# 迈克 #{{number}}",
"keyEvents": "关键事件",
"modelSwitches_few": "型号开关({{count}})",
"modelSwitches_many": "型号开关({{count}})"
"modelSwitches": "模型切换({{count}}",
"modelSwitches_one": "模型切换({{count}}",
"modelSwitches_other": "模型切换({{count}}",
"messageNumber": "消息#{{number}}",
"keyEvents": "重要事件",
"modelSwitches_few": "模型切换({{count}}",
"modelSwitches_many": "模型切换({{count}}"
},
"tools": {
"title": "工具使用",
"summary": "{{formattedCount}} 跨越{{toolCount}}工具的总通话量",
"summary": "共 {{formattedCount}} 次调用,涵盖 {{toolCount}} 个工具",
"columns": {
"tool": "工具",
"calls": "电话",
"calls": "调用次数",
"errors": "错误",
"successPercent": "成功率(%)",
"health": "卫生"
"successPercent": "成功率",
"health": "健康状态"
}
},
"git": {
"title": "Git 活动",
"commits": "提交",
"pushes": "推",
"linesAdded": "添加的行数",
"linesRemoved": "删除行",
"branchesCreated": "创建分支"
"pushes": "推",
"linesAdded": "新增行数",
"linesRemoved": "删除",
"branchesCreated": "创建分支"
},
"friction": {
"title": "Friction 信号",
"rate": "滑动率:{{rate}}百分比(%)",
"correctionsCount": "{{count}}正",
"correctionsCount_one": "{{count}}正",
"corrections": "惩戒",
"thrashingSignals": "闪烁信号",
"repeatedBashCommands": "重复的巴什命令",
"reworkedFiles": "重修的文件( 3+编辑)",
"correctionsCount_few": "{{count}}正",
"correctionsCount_many": "{{count}}正",
"correctionsCount_other": "{{count}}正"
"title": "摩擦信号",
"rate": "摩擦率:{{rate}}%",
"correctionsCount": "{{count}}正",
"correctionsCount_one": "{{count}}正",
"corrections": "修正",
"thrashingSignals": "反复修改信号",
"repeatedBashCommands": "重复的 Bash 命令",
"reworkedFiles": "返工文件3 次以上编辑)",
"correctionsCount_few": "{{count}}正",
"correctionsCount_many": "{{count}}正",
"correctionsCount_other": "{{count}}正"
},
"errors": {
"title": "错误",
"permissionDenied": "拒绝权限",
"messageIndex": "# 迈克 #{{index}}",
"input": "入",
"permissionDenied": "权限被拒绝",
"messageIndex": "消息#{{index}}",
"input": "入",
"error": "错误",
"count": "{{count}} 错误",
"count_one": "{{count}} 错误",
"permissionDenialCount": "{{count}} 许可拒绝",
"permissionDenialCount_one": "{{count}} 许可被拒绝",
"permissionDenialCount": "{{count}} 权限拒绝次数",
"permissionDenialCount_one": "{{count}} 权限拒绝次数",
"count_few": "{{count}} 错误",
"count_many": "{{count}} 错误",
"count_other": "{{count}} 错误",
"permissionDenialCount_few": "{{count}} 许可拒绝",
"permissionDenialCount_many": "{{count}} 许可拒绝",
"permissionDenialCount_other": "{{count}} 许可拒绝"
"permissionDenialCount_few": "{{count}} 权限拒绝次数",
"permissionDenialCount_many": "{{count}} 权限拒绝次数",
"permissionDenialCount_other": "{{count}} 权限拒绝次数"
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -34,12 +34,20 @@ function formatBytes(bytes: number | undefined): string {
return `${mb.toFixed(mb >= 10 ? 0 : 1)} MB`;
}
function buildStatusText(log: MemberRuntimeLogTailResponse | null): string | null {
function buildStatusText(
log: MemberRuntimeLogTailResponse | null,
labels: {
empty: string;
fileEmpty: string;
showingLast: (bytes: string) => string;
showing: (bytes: string) => string;
}
): string | null {
if (!log) return null;
if (log.missing) return 'No process log file captured for this member yet.';
if (!log.content) return 'Process log file is empty.';
if (log.truncated) return `Showing last ${formatBytes(log.bytesRead)}.`;
return `Showing ${formatBytes(log.bytesRead)}.`;
if (log.missing) return labels.empty;
if (!log.content) return labels.fileEmpty;
if (log.truncated) return labels.showingLast(formatBytes(log.bytesRead));
return labels.showing(formatBytes(log.bytesRead));
}
function ProcessLogKindTabs({
@ -204,7 +212,12 @@ export function MemberRuntimeProcessLogsPanel({
}
}, [log?.content]);
const statusText = buildStatusText(log);
const statusText = buildStatusText(log, {
empty: t('members.runtimeLogs.empty'),
fileEmpty: t('members.runtimeLogs.fileEmpty'),
showingLast: (bytes) => t('members.runtimeLogs.showingLast', { bytes }),
showing: (bytes) => t('members.runtimeLogs.showing', { bytes }),
});
const hasContent = Boolean(log?.content);
return (
@ -252,7 +265,7 @@ export function MemberRuntimeProcessLogsPanel({
disabled={!hasContent}
>
{copied ? <Check size={13} /> : <Clipboard size={13} />}
{copied ? 'Copied' : 'Copy'}
{copied ? tCommon('actions.copied') : t('members.runtimeLogs.copy')}
</button>
</div>
</div>

View file

@ -1,6 +1,9 @@
import { decideMemberWorkSyncStatus } from '../domain';
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
import {
attachMemberWorkSyncReportToken,
finalizeMemberWorkSyncAgenda,
} from './MemberWorkSyncReconciler';
import { resolveMemberWorkSyncRuntimeActivity } from './MemberWorkSyncRuntimeActivity';
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
@ -28,7 +31,7 @@ export class MemberWorkSyncDiagnosticsReader {
inactive: source.inactive || runtimeActivity.inactive,
});
return {
return attachMemberWorkSyncReportToken(this.deps, {
teamName: agenda.teamName,
memberName: agenda.memberName,
state: decision.state,
@ -46,6 +49,6 @@ export class MemberWorkSyncDiagnosticsReader {
'status_snapshot_not_persisted',
],
...(source.providerId ? { providerId: source.providerId } : {}),
};
});
}
}

View file

@ -155,6 +155,21 @@ function shouldPlanDeliveredStillStuckRecovery(input: {
);
}
function shouldRepairDeliveredAgendaSyncNudge(input: {
status: MemberWorkSyncStatus;
requestedInput: MemberWorkSyncOutboxEnsureInput;
existingItem: MemberWorkSyncOutboxItem;
}): boolean {
return (
input.status.state === 'needs_sync' &&
input.requestedInput.payload.workSyncIntent === 'agenda_sync' &&
input.existingItem.status === 'delivered' &&
input.existingItem.agendaFingerprint === input.requestedInput.agendaFingerprint &&
input.existingItem.payloadHash === input.requestedInput.payloadHash &&
!hasActiveAcceptedWorkLease(input.status)
);
}
function isOutboxItemAwaitingDelivery(item: MemberWorkSyncOutboxItem): boolean {
return item.status !== 'delivered' && item.status !== 'failed_terminal';
}
@ -296,6 +311,7 @@ export class MemberWorkSyncNudgeOutboxPlanner {
await this.appendPlanAudit(status, { planned: false, code: 'payload_conflict' });
return { planned: false, code: 'payload_conflict' };
}
await this.repairDeliveredAgendaSyncNudgeIfNeeded(status, recoveryInput, recoveryResult.item);
if (activationReason) {
const deliveredStillStuckRecovery = await this.planDeliveredStillStuckRecovery(
@ -371,6 +387,7 @@ export class MemberWorkSyncNudgeOutboxPlanner {
await this.appendPlanAudit(status, { planned: false, code: 'payload_conflict' });
return { planned: false, code: 'payload_conflict' };
}
await this.repairDeliveredAgendaSyncNudgeIfNeeded(status, recoveryInput, recoveryResult.item);
const recoveryPlanned = isOutboxItemAwaitingDelivery(recoveryResult.item);
const recoveryPlanResult = {
@ -491,6 +508,11 @@ export class MemberWorkSyncNudgeOutboxPlanner {
await this.appendPlanAudit(status, { planned: false, code: 'payload_conflict' });
return { planned: false, code: 'payload_conflict' };
}
await this.repairDeliveredAgendaSyncNudgeIfNeeded(
status,
recoveryInput,
recoveryResult.item
);
if (
shouldPlanStatusOnlyRecovery({
status,
@ -544,6 +566,7 @@ export class MemberWorkSyncNudgeOutboxPlanner {
await this.appendPlanAudit(status, { planned: false, code });
return { planned: false, code };
}
await this.repairDeliveredAgendaSyncNudgeIfNeeded(status, input, result.item);
if (
shouldPlanStatusOnlyRecovery({
status,
@ -580,6 +603,37 @@ export class MemberWorkSyncNudgeOutboxPlanner {
return planResult;
}
private async repairDeliveredAgendaSyncNudgeIfNeeded(
status: MemberWorkSyncStatus,
requestedInput: MemberWorkSyncOutboxEnsureInput,
existingItem: MemberWorkSyncOutboxItem
): Promise<void> {
const inboxNudge = this.deps.inboxNudge;
if (
!inboxNudge?.repairIfPresent ||
!shouldRepairDeliveredAgendaSyncNudge({ status, requestedInput, existingItem })
) {
return;
}
try {
await inboxNudge.repairIfPresent({
teamName: status.teamName,
memberName: status.memberName,
messageId: existingItem.deliveredMessageId ?? existingItem.id,
payloadHash: existingItem.payloadHash,
payload: existingItem.payload,
});
} catch (error) {
this.deps.logger?.warn('member work sync delivered nudge repair failed', {
teamName: status.teamName,
memberName: status.memberName,
outboxId: existingItem.id,
error: String(error),
});
}
}
private async appendReviewPickupEscalationAudit(
status: MemberWorkSyncStatus,
reason: string

View file

@ -1,6 +1,10 @@
import { MemberWorkSyncReporter } from './MemberWorkSyncReporter';
import type { MemberWorkSyncReportIntentStatus } from '../../contracts';
import type {
MemberWorkSyncReportIntent,
MemberWorkSyncReportIntentStatus,
MemberWorkSyncReportResult,
} from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports';
export interface MemberWorkSyncPendingReportReplaySummary {
@ -52,10 +56,7 @@ export class MemberWorkSyncPendingReportIntentReplayer {
let status: MemberWorkSyncReportIntentStatus = 'rejected';
let resultCode = 'replay_failed';
try {
const result = await this.reporter.execute({
...intent.request,
source: intent.request.source ?? 'mcp',
});
const result = await this.executeReplay(intent);
status = statusForResult(result);
resultCode = result.code;
} catch (error) {
@ -83,4 +84,56 @@ export class MemberWorkSyncPendingReportIntentReplayer {
return summary;
}
private async executeReplay(
intent: MemberWorkSyncReportIntent
): Promise<MemberWorkSyncReportResult> {
const result = await this.reporter.execute({
...intent.request,
source: intent.request.source ?? 'mcp',
});
const freshToken = await this.getFreshTokenForExpiredFallbackReport(intent, result);
if (!freshToken) {
return result;
}
return this.reporter.execute({
...intent.request,
agendaFingerprint: freshToken.agendaFingerprint,
reportToken: freshToken.reportToken,
source: intent.request.source ?? 'mcp',
});
}
private async getFreshTokenForExpiredFallbackReport(
intent: MemberWorkSyncReportIntent,
result: MemberWorkSyncReportResult
): Promise<{ agendaFingerprint: string; reportToken: string } | null> {
if (
result.accepted ||
result.code !== 'invalid_report_token' ||
intent.reason !== 'control_api_unavailable' ||
!intent.request.reportToken ||
!result.status.reportToken ||
result.status.agenda.fingerprint !== intent.request.agendaFingerprint ||
!this.deps.reportToken
) {
return null;
}
const validation = await this.deps.reportToken.verify({
token: intent.request.reportToken,
teamName: result.status.teamName,
memberName: result.status.memberName,
agendaFingerprint: result.status.agenda.fingerprint,
nowIso: this.deps.clock.now().toISOString(),
});
if (validation.ok || validation.reason !== 'expired') {
return null;
}
return {
agendaFingerprint: result.status.agenda.fingerprint,
reportToken: result.status.reportToken,
};
}
}

View file

@ -190,6 +190,13 @@ export interface MemberWorkSyncInboxNudgePort {
payload: MemberWorkSyncOutboxItem['payload'];
timestamp: string;
}): Promise<{ inserted: boolean; messageId: string; conflict?: boolean }>;
repairIfPresent?(input: {
teamName: string;
memberName: string;
messageId: string;
payloadHash: string;
payload: MemberWorkSyncOutboxItem['payload'];
}): Promise<{ found: boolean; repaired: boolean; conflict?: boolean }>;
}
export interface MemberWorkSyncWatchdogCooldownPort {

View file

@ -3,24 +3,48 @@ import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
import type { MemberWorkSyncInboxNudgePort } from '../../../core/application';
type TeamInboxMemberWorkSyncNudgeInput = Parameters<
MemberWorkSyncInboxNudgePort['insertIfAbsent']
>[0];
type TeamInboxMemberWorkSyncNudgeRepairInput = Parameters<
NonNullable<MemberWorkSyncInboxNudgePort['repairIfPresent']>
>[0];
type TeamInboxMemberWorkSyncNudgeWriter = Pick<TeamInboxWriter, 'sendMessage'> &
Partial<Pick<TeamInboxWriter, 'updateMessageText'>>;
function isStoredMemberWorkSyncNudge(
message: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>>[number]
): boolean {
return message.messageKind === 'member_work_sync_nudge';
}
export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudgePort {
constructor(
private readonly inboxReader: Pick<TeamInboxReader, 'getMessagesFor'> = new TeamInboxReader(),
private readonly inboxWriter: Pick<TeamInboxWriter, 'sendMessage'> = new TeamInboxWriter(),
private readonly inboxWriter: TeamInboxMemberWorkSyncNudgeWriter = new TeamInboxWriter(),
private readonly controlUrlResolver?: () => Promise<string | null> | string | null
) {}
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
async insertIfAbsent(input: TeamInboxMemberWorkSyncNudgeInput) {
const existing = await this.inboxReader.getMessagesFor(input.teamName, input.memberName);
const existingMessage = existing.find((message) => message.messageId === input.messageId);
if (existingMessage) {
if (existingMessage.workSyncPayloadHash !== input.payloadHash) {
if (
existingMessage.workSyncPayloadHash !== input.payloadHash ||
!isStoredMemberWorkSyncNudge(existingMessage)
) {
return { inserted: false, messageId: input.messageId, conflict: true };
}
await this.repairExistingControlUrlIfNeeded(input, existingMessage.text, {
required: Boolean(this.controlUrlResolver),
});
return { inserted: false, messageId: input.messageId };
}
const controlUrl = await this.resolveControlUrl();
const controlUrl = await this.resolveControlUrl({
required: Boolean(this.controlUrlResolver),
});
const text = controlUrl
? this.withControlUrl(input.payload.text, controlUrl)
: input.payload.text;
@ -48,27 +72,89 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
};
}
private async resolveControlUrl(): Promise<string | null> {
async repairIfPresent(input: TeamInboxMemberWorkSyncNudgeRepairInput) {
const existing = await this.inboxReader.getMessagesFor(input.teamName, input.memberName);
const existingMessage = existing.find((message) => message.messageId === input.messageId);
if (!existingMessage) {
return { found: false, repaired: false };
}
if (
existingMessage.workSyncPayloadHash !== input.payloadHash ||
!isStoredMemberWorkSyncNudge(existingMessage)
) {
return { found: true, repaired: false, conflict: true };
}
const repaired = await this.repairExistingControlUrlIfNeeded(input, existingMessage.text, {
required: Boolean(this.controlUrlResolver),
});
return { found: true, repaired };
}
private async repairExistingControlUrlIfNeeded(
input: TeamInboxMemberWorkSyncNudgeRepairInput,
existingText: string | undefined,
options: { required?: boolean } = {}
): Promise<boolean> {
const controlUrl = await this.resolveControlUrl(options);
if (!controlUrl) {
return false;
}
const currentText = existingText ?? input.payload.text;
const repairedText = this.withControlUrl(currentText, controlUrl);
if (repairedText === currentText) {
return false;
}
if (typeof this.inboxWriter.updateMessageText !== 'function') {
if (options.required) {
throw new Error('member work sync inbox text update unavailable');
}
return false;
}
const result = await this.inboxWriter.updateMessageText(input.teamName, {
member: input.memberName,
messageId: input.messageId,
text: repairedText,
expectedMessageKind: 'member_work_sync_nudge',
expectedWorkSyncPayloadHash: input.payloadHash,
});
return result.updated;
}
private async resolveControlUrl(options: { required?: boolean } = {}): Promise<string | null> {
if (!this.controlUrlResolver) {
return null;
}
let value: string | null | undefined;
try {
const value = await this.controlUrlResolver();
const trimmed = value?.trim();
return trimmed ? trimmed : null;
} catch {
value = await this.controlUrlResolver();
} catch (error) {
if (options.required) {
throw new Error(`member work sync control URL unavailable: ${String(error)}`);
}
return null;
}
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
if (options.required) {
throw new Error('member work sync control URL unavailable');
}
return null;
}
private withControlUrl(text: string, controlUrl: string): string {
if (text.includes('controlUrl')) {
const controlLine = `Required control API: pass controlUrl "${controlUrl}" in both member_work_sync_status and member_work_sync_report.`;
const existingControlLine =
/^Required control API: pass controlUrl "[^"\n]+" in both member_work_sync_status and member_work_sync_report\.$/m;
if (existingControlLine.test(text)) {
return text.replace(existingControlLine, controlLine);
}
if (text.includes(`controlUrl "${controlUrl}"`)) {
return text;
}
return [
text,
`Required control API: pass controlUrl "${controlUrl}" in both member_work_sync_status and member_work_sync_report.`,
].join('\n');
return [text, controlLine].join('\n');
}
}

View file

@ -7,6 +7,8 @@ import path from 'path';
import { isReservedMemberName, normalizeMemberName } from '../../../core/domain';
import { mergeTeamMembers } from './mergeTeamMembers';
import type {
RuntimeTurnSettledTargetResolution,
RuntimeTurnSettledTargetResolverPort,
@ -14,7 +16,7 @@ import type {
import type { RuntimeTurnSettledEvent } from '../../../core/domain';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
import type { TeamMember, TeamSummary } from '@shared/types';
import type { TeamMember, TeamProviderId, TeamSummary } from '@shared/types';
export interface RuntimeTurnSettledTeamSource {
listTeams(): Promise<TeamSummary[]>;
@ -38,26 +40,21 @@ function memberKey(member: Pick<TeamMember, 'name'>): string {
return normalizeMemberName(member.name);
}
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
const byName = new Map<string, TeamMember>();
for (const member of configMembers) {
const key = memberKey(member);
if (key) {
byName.set(key, member);
function providerIdFromBackend(providerBackendId: unknown): TeamProviderId | undefined {
const normalized = typeof providerBackendId === 'string' ? providerBackendId.trim() : '';
if (normalized === 'codex-native') {
return 'codex';
}
if (normalized === 'opencode-cli') {
return 'opencode';
}
for (const member of metaMembers) {
const key = memberKey(member);
if (key) {
byName.set(key, { ...byName.get(key), ...member });
}
}
return [...byName.values()];
return undefined;
}
function providerForMember(member: TeamMember | undefined): string | undefined {
function providerForMember(member: TeamMember | undefined): TeamProviderId | undefined {
return (
normalizeOptionalTeamProviderId(member?.providerId) ??
providerIdFromBackend(member?.providerBackendId) ??
inferTeamProviderIdFromModel(member?.model)
);
}
@ -202,7 +199,7 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT
const normalizedTarget = normalizeMemberName(memberName);
return (
mergeMembers(config.members ?? [], metaMembers).find(
mergeTeamMembers(config.members ?? [], metaMembers).find(
(member) => !member.removedAt && memberKey(member) === normalizedTarget
) ?? null
);

View file

@ -10,6 +10,8 @@ import {
normalizeMemberName,
} from '../../../core/domain';
import { mergeTeamMembers } from './mergeTeamMembers';
import type {
MemberWorkSyncAgendaSourcePort,
MemberWorkSyncAgendaSourceResult,
@ -19,7 +21,7 @@ import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import type { TeamMember } from '@shared/types';
import type { TeamMember, TeamProviderId } from '@shared/types';
export interface TeamTaskAgendaSourceDeps {
configReader: Pick<TeamConfigReader, 'getConfig'>;
@ -34,26 +36,21 @@ function memberKey(member: Pick<TeamMember, 'name'>): string {
return normalizeMemberName(member.name);
}
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
const byName = new Map<string, TeamMember>();
for (const member of configMembers) {
const key = memberKey(member);
if (key) {
byName.set(key, member);
function providerIdFromBackend(providerBackendId: unknown): TeamProviderId | undefined {
const normalized = typeof providerBackendId === 'string' ? providerBackendId.trim() : '';
if (normalized === 'codex-native') {
return 'codex';
}
if (normalized === 'opencode-cli') {
return 'opencode';
}
for (const member of metaMembers) {
const key = memberKey(member);
if (key) {
byName.set(key, { ...byName.get(key), ...member });
}
}
return [...byName.values()];
return undefined;
}
function toMemberLike(member: TeamMember): MemberWorkSyncMemberLike {
const providerId =
normalizeOptionalTeamProviderId(member.providerId) ??
providerIdFromBackend(member.providerBackendId) ??
inferTeamProviderIdFromModel(member.model);
return {
name: member.name,
@ -74,7 +71,7 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
}
const metaMembers = await this.deps.membersMetaStore.getMembers(teamName);
return mergeMembers(config.members ?? [], metaMembers)
return mergeTeamMembers(config.members ?? [], metaMembers)
.filter((member) => !member.removedAt)
.map((member) => normalizeMemberName(member.name))
.filter((memberName) => memberName.length > 0 && !isReservedMemberName(memberName))
@ -107,7 +104,7 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
this.deps.kanbanManager.getState(input.teamName),
this.deps.membersMetaStore.getMembers(input.teamName),
]);
const members = mergeMembers(config.members ?? [], metaMembers);
const members = mergeTeamMembers(config.members ?? [], metaMembers);
const activeMemberNames = members
.filter((member) => !member.removedAt)
.map((member) => normalizeMemberName(member.name))
@ -116,6 +113,7 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
const member = members.find((candidate) => memberKey(candidate) === normalizedMemberName);
const providerId =
normalizeOptionalTeamProviderId(member?.providerId) ??
providerIdFromBackend(member?.providerBackendId) ??
inferTeamProviderIdFromModel(member?.model);
const agenda = buildActionableWorkAgenda({

View file

@ -12,7 +12,10 @@ interface StallJournalEntry {
alertedAt?: string;
}
type WatchdogCooldownResult = { active: boolean; retryAfterIso?: string };
interface WatchdogCooldownResult {
active: boolean;
retryAfterIso?: string;
}
function parseTime(value: string | undefined): number | null {
if (!value) {

View file

@ -0,0 +1,110 @@
import {
inferTeamProviderIdFromModel,
normalizeOptionalTeamProviderId,
} from '@shared/utils/teamProvider';
import { normalizeMemberName } from '../../../core/domain';
import type { TeamMember, TeamProviderId } from '@shared/types';
function memberKey(member: Pick<TeamMember, 'name'>): string {
return normalizeMemberName(member.name);
}
const PROVIDER_SCOPED_MEMBER_FIELDS = new Set<keyof TeamMember>([
'providerId',
'providerBackendId',
'model',
'effort',
'fastMode',
]);
const PROVIDER_SETTING_MEMBER_FIELDS = new Set<keyof TeamMember>(['effort', 'fastMode']);
function hasProviderIdentityFields(member: TeamMember | undefined): boolean {
return providerIdForMember(member) !== undefined;
}
function inferProviderIdFromBackend(providerBackendId: unknown): TeamProviderId | undefined {
const normalized = typeof providerBackendId === 'string' ? providerBackendId.trim() : '';
if (normalized === 'codex-native') {
return 'codex';
}
if (normalized === 'opencode-cli') {
return 'opencode';
}
return undefined;
}
function providerIdForMember(member: TeamMember | undefined): TeamProviderId | undefined {
return (
normalizeOptionalTeamProviderId(member?.providerId) ??
inferProviderIdFromBackend(member?.providerBackendId) ??
inferTeamProviderIdFromModel(member?.model)
);
}
function shouldPreserveBaseProviderScopedField(
base: TeamMember | undefined,
key: keyof TeamMember
): boolean {
if (!base || !PROVIDER_SCOPED_MEMBER_FIELDS.has(key)) {
return false;
}
if (hasProviderIdentityFields(base)) {
return true;
}
return PROVIDER_SETTING_MEMBER_FIELDS.has(key) && base[key] !== undefined;
}
function mergeDefinedMemberFields(base: TeamMember | undefined, overlay: TeamMember): TeamMember {
const merged: TeamMember = { ...(base ?? { name: overlay.name }) };
const overlayProviderId = normalizeOptionalTeamProviderId(overlay.providerId);
const overlayHasProviderId = overlayProviderId !== undefined;
const baseProviderId = providerIdForMember(base);
const providerChanged =
overlayHasProviderId && baseProviderId !== undefined && overlayProviderId !== baseProviderId;
if (providerChanged) {
for (const key of PROVIDER_SCOPED_MEMBER_FIELDS) {
delete merged[key];
}
}
for (const [key, value] of Object.entries(overlay) as [
keyof TeamMember,
TeamMember[keyof TeamMember],
][]) {
if (value !== undefined) {
if (!overlayHasProviderId && shouldPreserveBaseProviderScopedField(base, key)) {
continue;
}
merged[key] = value as never;
}
}
if (
Object.prototype.hasOwnProperty.call(overlay, 'removedAt') &&
overlay.removedAt === undefined
) {
delete merged.removedAt;
}
return merged;
}
export function mergeTeamMembers(
configMembers: TeamMember[],
metaMembers: TeamMember[]
): TeamMember[] {
const byName = new Map<string, TeamMember>();
for (const member of configMembers) {
const key = memberKey(member);
if (key) {
byName.set(key, member);
}
}
for (const member of metaMembers) {
const key = memberKey(member);
if (key) {
byName.set(key, mergeDefinedMemberFields(byName.get(key), member));
}
}
return [...byName.values()];
}

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
hasUncertainWorkSyncRuntimeActivity,
hasWorkSyncActiveRuntime,
hasWorkSyncReachableRuntime,
isRuntimeEntryActiveForWorkSync,
isRuntimeMemberActiveForWorkSync,
isRuntimeMemberActivityUncertainForWorkSync,
@ -87,6 +88,60 @@ describe('member work sync team activity', () => {
).toBe(false);
});
it('does not treat lead process evidence as active for ordinary teammates', () => {
for (const livenessKind of [undefined, 'runtime_process', 'confirmed_bootstrap'] as const) {
const snapshot = createRuntimeSnapshot({
alice: createRuntimeEntry({
memberName: 'alice',
backendType: 'process',
livenessKind,
pidSource: 'lead_process',
}),
});
expect(isRuntimeEntryActiveForWorkSync(snapshot.members.alice)).toBe(false);
expect(hasWorkSyncActiveRuntime(snapshot)).toBe(false);
expect(hasWorkSyncReachableRuntime(snapshot)).toBe(false);
expect(isRuntimeMemberActiveForWorkSync(snapshot, 'alice')).toBe(false);
}
});
it('keeps active lead processes reachable for targeted lead work-sync', () => {
const snapshot = createRuntimeSnapshot({
'team-lead': createRuntimeEntry({
memberName: 'team-lead',
backendType: 'lead',
livenessKind: undefined,
pidSource: 'lead_process',
}),
alice: createRuntimeEntry({
memberName: 'alice',
alive: false,
livenessKind: 'stale_metadata',
}),
});
expect(hasWorkSyncActiveRuntime(snapshot)).toBe(false);
expect(hasWorkSyncReachableRuntime(snapshot)).toBe(true);
expect(isRuntimeMemberActiveForWorkSync(snapshot, 'team-lead')).toBe(true);
expect(isRuntimeMemberActiveForWorkSync(snapshot, 'alice')).toBe(false);
});
it('keeps ordinary teammates named lead active from normal agent process evidence', () => {
const snapshot = createRuntimeSnapshot({
lead: createRuntimeEntry({
memberName: 'lead',
backendType: 'process',
livenessKind: 'confirmed_bootstrap',
pidSource: 'agent_process_table',
}),
});
expect(hasWorkSyncActiveRuntime(snapshot)).toBe(true);
expect(hasWorkSyncReachableRuntime(snapshot)).toBe(true);
expect(isRuntimeMemberActiveForWorkSync(snapshot, 'lead')).toBe(true);
});
it('does not treat inactive liveness diagnostics as active by themselves', () => {
for (const livenessKind of [
'permission_blocked',

View file

@ -88,6 +88,22 @@ function getAcceptedWorkLeaseStaleness(
return reportExpiresAtMs <= nowMs ? 'expired' : null;
}
function getReportTokenStaleness(
status: MemberWorkSyncStatus,
nowMs: number
): 'missing' | 'expired' | null {
if (!status.reportToken?.trim()) {
return 'missing';
}
const tokenExpiresAtMs = Date.parse(status.reportTokenExpiresAt ?? '');
if (!Number.isFinite(tokenExpiresAtMs) || !Number.isFinite(nowMs)) {
return 'missing';
}
return tokenExpiresAtMs <= nowMs ? 'expired' : null;
}
function isEmptyAgendaStaleState(status: MemberWorkSyncStatus): boolean {
return (
status.agenda.items.length === 0 &&
@ -99,6 +115,10 @@ function isEmptyAgendaStaleState(status: MemberWorkSyncStatus): boolean {
}
function statusNeedsBackgroundRefresh(status: MemberWorkSyncStatus, nowMs: number): boolean {
if (getReportTokenStaleness(status, nowMs) !== null) {
return true;
}
if (isEmptyAgendaStaleState(status)) {
return true;
}
@ -125,6 +145,13 @@ function statusNeedsBackgroundRefresh(status: MemberWorkSyncStatus, nowMs: numbe
function getStatusStalenessDiagnostics(status: MemberWorkSyncStatus, nowMs: number): string[] {
const diagnostics: string[] = [];
const tokenStaleness = getReportTokenStaleness(status, nowMs);
if (tokenStaleness === 'missing') {
diagnostics.push('report_token_missing_refresh_enqueued');
} else if (tokenStaleness === 'expired') {
diagnostics.push('report_token_expired_refresh_enqueued');
}
const evaluatedAtMs = Date.parse(status.evaluatedAt);
if (!Number.isFinite(evaluatedAtMs)) {
diagnostics.push('status_evaluated_at_invalid');
@ -150,6 +177,12 @@ function getStatusStalenessDiagnostics(status: MemberWorkSyncStatus, nowMs: numb
return [...new Set(diagnostics)];
}
function shouldRefreshStatusSynchronously(stalenessDiagnostics: string[]): boolean {
return stalenessDiagnostics.some(
(diagnostic) => diagnostic !== 'caught_up_stale_refresh_enqueued'
);
}
export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: {
teamsBasePath: string;
provider: RuntimeTurnSettledProvider;
@ -505,6 +538,21 @@ export function createMemberWorkSyncFeature(deps: {
if (stalenessDiagnostics.length === 0) {
return status;
}
if (shouldRefreshStatusSynchronously(stalenessDiagnostics)) {
try {
return await reconciler.execute(request, {
reconciledBy: 'request',
triggerReasons: ['manual_refresh'],
});
} catch (error) {
deps.logger?.warn('member work sync synchronous status refresh failed', {
teamName: status.teamName,
memberName: status.memberName,
diagnostics: stalenessDiagnostics,
error: String(error),
});
}
}
queue.enqueue({
teamName: status.teamName,
memberName: status.memberName,

View file

@ -26,11 +26,46 @@ const WORK_SYNC_BOOTSTRAP_ONLY_PID_SOURCES = new Set<TeamAgentRuntimePidSource>(
'persisted_metadata',
]);
const WORK_SYNC_CONFIRMED_BOOTSTRAP_ACTIVE_PID_SOURCES = new Set<TeamAgentRuntimePidSource>([
const WORK_SYNC_MEMBER_CONFIRMED_BOOTSTRAP_ACTIVE_PID_SOURCES = new Set<TeamAgentRuntimePidSource>([
'agent_process_table',
'opencode_bridge',
]);
const WORK_SYNC_LEAD_CONFIRMED_BOOTSTRAP_ACTIVE_PID_SOURCES = new Set<TeamAgentRuntimePidSource>([
'lead_process',
]);
function isWorkSyncLeadLikeMemberName(memberName: string): boolean {
const normalized = normalizeMemberName(memberName).replace(/[\s_]+/g, '-');
return (
normalized === 'lead' ||
normalized === 'team-lead' ||
normalized === 'teamlead' ||
normalized === 'team-leader'
);
}
function hasActiveWorkSyncProcessEvidence(
entry: Pick<TeamAgentRuntimeEntry, 'alive' | 'livenessKind' | 'pidSource'> | null | undefined,
confirmedBootstrapActivePidSources: ReadonlySet<TeamAgentRuntimePidSource>
): boolean {
if (entry?.alive !== true) {
return false;
}
if (
entry.livenessKind === 'confirmed_bootstrap' &&
(!entry.pidSource ||
WORK_SYNC_BOOTSTRAP_ONLY_PID_SOURCES.has(entry.pidSource) ||
!confirmedBootstrapActivePidSources.has(entry.pidSource))
) {
return false;
}
if (!entry.livenessKind) {
return true;
}
return !WORK_SYNC_INACTIVE_LIVENESS_KINDS.has(entry.livenessKind);
}
export function isRuntimeEntryActiveForWorkSync(
entry:
| Pick<
@ -40,7 +75,7 @@ export function isRuntimeEntryActiveForWorkSync(
| null
| undefined
): boolean {
if (entry?.alive !== true) {
if (!entry) {
return false;
}
if (
@ -50,17 +85,33 @@ export function isRuntimeEntryActiveForWorkSync(
return false;
}
if (
entry.livenessKind === 'confirmed_bootstrap' &&
(!entry.pidSource ||
WORK_SYNC_BOOTSTRAP_ONLY_PID_SOURCES.has(entry.pidSource) ||
!WORK_SYNC_CONFIRMED_BOOTSTRAP_ACTIVE_PID_SOURCES.has(entry.pidSource))
entry.pidSource &&
WORK_SYNC_LEAD_CONFIRMED_BOOTSTRAP_ACTIVE_PID_SOURCES.has(entry.pidSource)
) {
return false;
}
if (!entry.livenessKind) {
return true;
return hasActiveWorkSyncProcessEvidence(
entry,
WORK_SYNC_MEMBER_CONFIRMED_BOOTSTRAP_ACTIVE_PID_SOURCES
);
}
return !WORK_SYNC_INACTIVE_LIVENESS_KINDS.has(entry.livenessKind);
function isRuntimeLeadEntryActiveForWorkSync(
entry:
| Pick<
TeamAgentRuntimeEntry,
'alive' | 'backendType' | 'livenessKind' | 'memberName' | 'pidSource'
>
| null
| undefined
): boolean {
if (!entry || !isWorkSyncLeadLikeMemberName(entry.memberName)) {
return false;
}
return (
entry.backendType === 'lead' &&
hasActiveWorkSyncProcessEvidence(entry, WORK_SYNC_LEAD_CONFIRMED_BOOTSTRAP_ACTIVE_PID_SOURCES)
);
}
function isRuntimeEntryRelevantForWorkSync(
@ -95,6 +146,14 @@ export function hasWorkSyncActiveRuntime(
return Object.values(snapshot?.members ?? {}).some(isRuntimeEntryActiveForWorkSync);
}
export function hasWorkSyncReachableRuntime(
snapshot: Pick<TeamAgentRuntimeSnapshot, 'members'> | null | undefined
): boolean {
return Object.values(snapshot?.members ?? {}).some(
(entry) => isRuntimeEntryActiveForWorkSync(entry) || isRuntimeLeadEntryActiveForWorkSync(entry)
);
}
export function isRuntimeMemberActiveForWorkSync(
snapshot: Pick<TeamAgentRuntimeSnapshot, 'members'> | null | undefined,
memberName: string
@ -106,7 +165,9 @@ export function isRuntimeMemberActiveForWorkSync(
return Object.values(snapshot?.members ?? {}).some(
(entry) =>
normalizeMemberName(entry.memberName) === normalizedMemberName &&
isRuntimeEntryActiveForWorkSync(entry)
(isRuntimeEntryActiveForWorkSync(entry) ||
(isWorkSyncLeadLikeMemberName(normalizedMemberName) &&
isRuntimeLeadEntryActiveForWorkSync(entry)))
);
}

View file

@ -11,6 +11,7 @@ export {
export {
hasUncertainWorkSyncRuntimeActivity,
hasWorkSyncActiveRuntime,
hasWorkSyncReachableRuntime,
isRuntimeEntryActiveForWorkSync,
isRuntimeMemberActiveForWorkSync,
isRuntimeMemberActivityUncertainForWorkSync,

View file

@ -17,36 +17,54 @@ export interface RunningTeamRowModel {
taskCounts?: TaskStatusCounts;
}
function getStatusLabel(status: RunningTeamDashboardEntry['status']): string {
export interface RunningTeamsSectionText {
status: Record<RunningTeamDashboardEntry['status'], string>;
noProject: string;
}
const DEFAULT_TEXT: RunningTeamsSectionText = {
status: {
active: 'Active',
provisioning: 'Launching',
idle: 'Running',
},
noProject: 'No project',
};
function getStatusLabel(
status: RunningTeamDashboardEntry['status'],
text: RunningTeamsSectionText
): string {
switch (status) {
case 'active':
return 'Active';
return text.status.active;
case 'provisioning':
return 'Launching';
return text.status.provisioning;
case 'idle':
return 'Running';
return text.status.idle;
}
}
function getProjectLabel(projectPath?: string): string {
function getProjectLabel(projectPath: string | undefined, text: RunningTeamsSectionText): string {
if (!projectPath) {
return 'No project';
return text.noProject;
}
return getBaseName(projectPath) || projectPath;
}
export function adaptRunningTeamsSection(
teams: RunningTeamDashboardEntry[]
teams: RunningTeamDashboardEntry[],
text: RunningTeamsSectionText = DEFAULT_TEXT
): RunningTeamRowModel[] {
return teams.map((team) => ({
id: team.teamName,
teamName: team.teamName,
displayName: team.displayName,
projectPath: team.projectPath,
projectLabel: getProjectLabel(team.projectPath),
projectLabel: getProjectLabel(team.projectPath, text),
status: team.status,
statusLabel: getStatusLabel(team.status),
statusLabel: getStatusLabel(team.status, text),
iconColor: team.color
? getTeamColorSet(team.color).border
: nameColorSet(team.displayName).border,

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import {
@ -58,6 +59,7 @@ function toCandidate(input: {
}
export function useRunningTeamsSection(searchQuery: string): RunningTeamsSectionState {
const { t } = useAppTranslation('team');
const {
teams,
globalTasks,
@ -172,7 +174,14 @@ export function useRunningTeamsSection(searchQuery: string): RunningTeamsSection
),
});
return adaptRunningTeamsSection(runningTeams);
return adaptRunningTeamsSection(runningTeams, {
status: {
active: t('runningTeams.status.active'),
provisioning: t('runningTeams.status.provisioning'),
idle: t('runningTeams.status.idle'),
},
noProject: t('runningTeams.noProject'),
});
}, [
aliveTeams,
globalTasks,
@ -182,6 +191,7 @@ export function useRunningTeamsSection(searchQuery: string): RunningTeamsSection
provisioningTeamNames,
searchActive,
teams,
t,
]);
const openRunningTeam = useCallback(

View file

@ -26,6 +26,7 @@ import {
getOpenCodeTeamModelRecommendation,
isOpenCodeTeamModelRecommended,
} from '@renderer/utils/openCodeModelRecommendations';
import { isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic } from '@shared/utils/openCodeWindowsAccessDenied';
import {
AlertTriangle,
Check,
@ -785,6 +786,20 @@ function getRuntimeProviderDiagnosticRows(
.map(([label, value]) => [label, String(value)]);
}
function isOpenCodeWindowsNodeModulesSymlinkPermissionError(
message: string,
diagnostics: RuntimeProviderManagementErrorDiagnosticsDto | null | undefined
): boolean {
const value = [
message,
diagnostics?.stderrPreview ?? '',
diagnostics?.stdoutPreview ?? '',
diagnostics?.likelyCause ?? '',
...(diagnostics?.hints ?? []),
].join('\n');
return isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic(value);
}
async function writeRuntimeProviderDiagnosticsToClipboard(text: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
@ -826,6 +841,10 @@ const RuntimeProviderErrorAlert = ({
const [headline = message, ...detailLines] = message.trim().split(/\r?\n/);
const fallbackDetails = detailLines.join('\n').trim();
const hints = diagnostics?.hints ?? [];
const showWindowsSymlinkPermissionHint = isOpenCodeWindowsNodeModulesSymlinkPermissionError(
message,
diagnostics
);
const copyText = useMemo(
() => formatRuntimeProviderDiagnosticsCopyText(message, diagnostics),
[diagnostics, message]
@ -859,6 +878,11 @@ const RuntimeProviderErrorAlert = ({
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<div className="min-w-0 whitespace-pre-wrap break-words font-medium leading-5">
{headline || message}
{showWindowsSymlinkPermissionHint ? (
<span className="ml-2 inline-flex rounded border border-red-200/30 bg-red-500/10 px-1.5 py-0.5 text-[11px] font-semibold leading-4 text-red-50">
{t('runtimeProvider.diagnostics.windowsSymlinkAdminHint')}
</span>
) : null}
</div>
<Button
type="button"

View file

@ -112,6 +112,90 @@ describe('planTeamRuntimeLanes', () => {
});
});
it('creates worktree-root OpenCode lanes for pure OpenCode teams with isolated members', () => {
const result = planTeamRuntimeLanes({
leadProviderId: 'opencode',
baseCwd: '/repo',
members: [
{
name: 'bob',
providerId: 'opencode',
model: 'minimax-m2.5-free',
cwd: '/repo/.worktrees/bob',
},
{
name: 'tom',
providerId: 'opencode',
model: 'nemotron-3-super-free',
cwd: '/repo/.worktrees/tom',
},
],
});
expect(result).toMatchObject({
ok: true,
plan: {
mode: 'pure_opencode_worktree_root_lanes',
primaryMembers: [],
sideLanes: [
{
laneId: 'secondary:opencode:bob',
providerId: 'opencode',
member: expect.objectContaining({
name: 'bob',
providerId: 'opencode',
cwd: '/repo/.worktrees/bob',
}),
},
{
laneId: 'secondary:opencode:tom',
providerId: 'opencode',
member: expect.objectContaining({
name: 'tom',
providerId: 'opencode',
cwd: '/repo/.worktrees/tom',
}),
},
],
},
});
});
it('keeps base-cwd OpenCode members on primary and isolated members on worktree lanes', () => {
const result = planTeamRuntimeLanes({
leadProviderId: 'opencode',
baseCwd: '/repo',
members: [
{ name: 'lead-dev', providerId: 'opencode', model: 'big-pickle' },
{
name: 'bob',
providerId: 'opencode',
model: 'minimax-m2.5-free',
cwd: '/repo/.worktrees/bob',
},
],
});
expect(result).toMatchObject({
ok: true,
plan: {
mode: 'pure_opencode_worktree_root_lanes',
primaryMembers: [expect.objectContaining({ name: 'lead-dev', providerId: 'opencode' })],
sideLanes: [
{
laneId: 'secondary:opencode:bob',
providerId: 'opencode',
member: expect.objectContaining({
name: 'bob',
providerId: 'opencode',
cwd: '/repo/.worktrees/bob',
}),
},
],
},
});
});
it('creates a secondary OpenCode lane for an Anthropic-led mixed team', () => {
const result = planTeamRuntimeLanes({
leadProviderId: 'anthropic',

View file

@ -44,6 +44,16 @@ export type TeamRuntimeLanePlan =
allMembers: PlannedRuntimeMember[];
sideLanes: [];
}
| {
mode: 'pure_opencode_worktree_root_lanes';
primaryMembers: PlannedRuntimeMember[];
allMembers: PlannedRuntimeMember[];
sideLanes: {
laneId: string;
providerId: 'opencode';
member: PlannedRuntimeMember;
}[];
}
| {
mode: 'mixed_opencode_side_lanes';
primaryMembers: PlannedRuntimeMember[];
@ -111,9 +121,16 @@ export function buildPlannedMemberLaneIdentity(params: {
};
}
export function buildOpenCodeSecondaryLaneId(
member: Pick<RuntimeLanePlannerMemberInput, 'name'>
): string {
return `secondary:opencode:${member.name.trim()}`;
}
export function planTeamRuntimeLanes(params: {
leadProviderId?: TeamProviderId;
members: readonly RuntimeLanePlannerMemberInput[];
baseCwd?: string;
}): TeamRuntimeLanePlanResult {
const leadProviderId = normalizeLeadProviderId(params.leadProviderId);
const allMembers = normalizePlannedMembers(params.members, leadProviderId);
@ -129,6 +146,27 @@ export function planTeamRuntimeLanes(params: {
'Mixed teams with an OpenCode lead are not supported in this phase. Keep the team lead on Anthropic or Codex when you mix OpenCode with other providers.',
};
}
const normalizedBaseCwd = params.baseCwd?.trim();
const worktreeRootMembers = allMembers.filter((member) => {
const memberCwd = member.cwd?.trim();
return Boolean(memberCwd && (!normalizedBaseCwd || memberCwd !== normalizedBaseCwd));
});
if (worktreeRootMembers.length > 0 && allMembers.length > 1) {
const worktreeRootMemberNames = new Set(worktreeRootMembers.map((member) => member.name));
return {
ok: true,
plan: {
mode: 'pure_opencode_worktree_root_lanes',
primaryMembers: allMembers.filter((member) => !worktreeRootMemberNames.has(member.name)),
allMembers,
sideLanes: worktreeRootMembers.map((member) => ({
laneId: buildOpenCodeSecondaryLaneId(member),
providerId: 'opencode',
member,
})),
},
};
}
return {
ok: true,
plan: {
@ -175,18 +213,37 @@ export function isMixedOpenCodeSideLanePlan(
return plan.mode === 'mixed_opencode_side_lanes';
}
export function isOpenCodeSideLanePlan(
plan: TeamRuntimeLanePlan
): plan is Extract<
TeamRuntimeLanePlan,
{ mode: 'mixed_opencode_side_lanes' | 'pure_opencode_worktree_root_lanes' }
> {
return (
plan.mode === 'mixed_opencode_side_lanes' || plan.mode === 'pure_opencode_worktree_root_lanes'
);
}
export function isPureOpenCodeLanePlan(
plan: TeamRuntimeLanePlan
): plan is Extract<TeamRuntimeLanePlan, { mode: 'pure_opencode' }> {
return plan.mode === 'pure_opencode';
}
export function isPureOpenCodeWorktreeRootLanePlan(
plan: TeamRuntimeLanePlan
): plan is Extract<TeamRuntimeLanePlan, { mode: 'pure_opencode_worktree_root_lanes' }> {
return plan.mode === 'pure_opencode_worktree_root_lanes';
}
export function fromProvisioningMembers(
leadProviderId: TeamProviderId | undefined,
members: readonly TeamProvisioningMemberInput[]
members: readonly TeamProvisioningMemberInput[],
options: { baseCwd?: string } = {}
): TeamRuntimeLanePlanResult {
return planTeamRuntimeLanes({
leadProviderId,
baseCwd: options.baseCwd,
members: members.map((member) => ({
name: member.name,
role: member.role,

View file

@ -9,9 +9,12 @@ export type {
TeamRuntimeLanePlanSuccess,
} from './core/domain/planTeamRuntimeLanes';
export {
buildOpenCodeSecondaryLaneId,
buildPlannedMemberLaneIdentity,
fromProvisioningMembers,
isMixedOpenCodeSideLanePlan,
isOpenCodeSideLanePlan,
isPureOpenCodeLanePlan,
isPureOpenCodeWorktreeRootLanePlan,
planTeamRuntimeLanes,
} from './core/domain/planTeamRuntimeLanes';

View file

@ -45,7 +45,7 @@ describe('createTeamRuntimeLaneCoordinator', () => {
{ name: 'tom', providerId: 'opencode', model: 'minimax-m2.5-free' },
],
})
).toThrow('Mixed teams with OpenCode side lanes require the OpenCode runtime adapter');
).toThrow('OpenCode side lanes require the OpenCode runtime adapter');
});
it('drops stale hard-failure reasons when secondary OpenCode evidence later confirms alive', () => {

View file

@ -1,7 +1,7 @@
import { buildMixedPersistedLaunchSnapshot } from '@features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot';
import {
fromProvisioningMembers,
isMixedOpenCodeSideLanePlan,
isOpenCodeSideLanePlan,
type TeamRuntimeLanePlan,
} from '@features/team-runtime-lanes/core/domain/planTeamRuntimeLanes';
@ -11,6 +11,7 @@ export interface TeamRuntimeLaneCoordinator {
planProvisioningMembers(params: {
leadProviderId?: TeamProviderId;
members: TeamCreateRequest['members'];
baseCwd?: string;
hasOpenCodeRuntimeAdapter: boolean;
}): TeamRuntimeLanePlan;
buildAggregateLaunchSnapshot(
@ -22,13 +23,15 @@ export interface TeamRuntimeLaneCoordinator {
export function createTeamRuntimeLaneCoordinator(): TeamRuntimeLaneCoordinator {
return {
planProvisioningMembers(params) {
const lanePlan = fromProvisioningMembers(params.leadProviderId, params.members);
const lanePlan = fromProvisioningMembers(params.leadProviderId, params.members, {
baseCwd: params.baseCwd,
});
if (!lanePlan.ok) {
throw new Error(lanePlan.message);
}
if (isMixedOpenCodeSideLanePlan(lanePlan.plan) && !params.hasOpenCodeRuntimeAdapter) {
if (isOpenCodeSideLanePlan(lanePlan.plan) && !params.hasOpenCodeRuntimeAdapter) {
throw new Error(
'Mixed teams with OpenCode side lanes require the OpenCode runtime adapter to be registered.'
'OpenCode side lanes require the OpenCode runtime adapter to be registered.'
);
}
return lanePlan.plan;
@ -37,7 +40,7 @@ export function createTeamRuntimeLaneCoordinator(): TeamRuntimeLaneCoordinator {
return buildMixedPersistedLaunchSnapshot(params);
},
isMixedSideLanePlan(plan) {
return isMixedOpenCodeSideLanePlan(plan);
return isOpenCodeSideLanePlan(plan);
},
};
}

View file

@ -41,7 +41,7 @@ import {
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature,
hasUncertainWorkSyncRuntimeActivity,
hasWorkSyncActiveRuntime,
hasWorkSyncReachableRuntime,
isRuntimeMemberActivityUncertainForWorkSync,
isRuntimeMemberActiveForWorkSync,
type MemberWorkSyncFeatureFacade,
@ -1919,7 +1919,7 @@ async function initializeServices(): Promise<void> {
if (!snapshot) {
return null;
}
const active = hasWorkSyncActiveRuntime(snapshot);
const active = hasWorkSyncReachableRuntime(snapshot);
if (!active && hasUncertainWorkSyncRuntimeActivity(snapshot)) {
return null;
}
@ -2037,7 +2037,12 @@ async function initializeServices(): Promise<void> {
isBusy: (input) => teamProvisioningService.getOpenCodeMemberDeliveryBusyStatus(input),
},
],
resolveControlUrl: async () => getTeamControlApiBaseUrl(),
resolveControlUrl: async () => {
if (!httpServer.isRunning()) {
await startHttpServer(handleModeSwitch);
}
return getTeamControlApiBaseUrl();
},
proofMissingRecoveryGuard: {
shouldDispatch: async (input) => {
const isOpenCodeRecipient = await teamProvisioningService

View file

@ -240,7 +240,17 @@ import type {
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
const logger = createLogger('IPC:teams');
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
// Runtime relay continues in the background after this race; keep sendMessage IPC off the
// 25s OpenCode turn-settled guard while still giving prompt acceptance/reconcile time.
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 6_000;
const OPENCODE_RUNTIME_DELIVERY_STATUS_AFTER_UI_TIMEOUT_MS = 1_000;
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON =
'opencode_runtime_delivery_ui_timeout_pending';
type OpenCodeMemberInboxRelayResult = Awaited<
ReturnType<TeamProvisioningService['relayOpenCodeMemberInboxMessages']>
>;
type OpenCodeMemberInboxDelivery = NonNullable<OpenCodeMemberInboxRelayResult['lastDelivery']>;
type VisibleDirectReplyProtocol = 'send_message' | 'agent_teams_message_send';
@ -318,6 +328,158 @@ async function withTimeoutValue<T>(
}
}
async function waitForOpenCodeRuntimeRelayForUi(input: {
provisioning: TeamProvisioningService;
teamName: string;
memberName: string;
messageId: string;
relayPromise: Promise<OpenCodeMemberInboxRelayResult>;
timeoutMs?: number;
}): Promise<OpenCodeMemberInboxRelayResult> {
let timer: ReturnType<typeof setTimeout> | null = null;
let timedOut = false;
void input.relayPromise.then(
(relay) => {
if (!timedOut) return;
const delivery = relay.lastDelivery;
if (delivery && !delivery.delivered && delivery.reason !== 'recipient_is_not_opencode') {
logger.warn(
`OpenCode runtime delivery after sendMessage completed after UI timeout for teammate "${input.memberName}" with failure: ${
delivery.reason ?? 'unknown error'
}`
);
}
},
(error: unknown) => {
if (!timedOut) return;
logger.warn(
`OpenCode runtime delivery after sendMessage rejected after UI timeout for teammate "${input.memberName}": ${getErrorMessage(error)}`
);
}
);
try {
const outcome = await Promise.race<
{ kind: 'relay'; relay: OpenCodeMemberInboxRelayResult } | { kind: 'timeout' }
>([
input.relayPromise.then((relay) => ({ kind: 'relay' as const, relay })),
new Promise<{ kind: 'timeout' }>((resolve) => {
timer = setTimeout(() => {
timedOut = true;
resolve({ kind: 'timeout' });
}, input.timeoutMs ?? OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS);
timer.unref?.();
}),
]);
if (outcome.kind === 'relay') {
return outcome.relay;
}
try {
const status = await withTimeoutValue(
input.provisioning.getOpenCodeRuntimeDeliveryStatus(input.teamName, input.messageId),
OPENCODE_RUNTIME_DELIVERY_STATUS_AFTER_UI_TIMEOUT_MS,
null
);
if (status) {
return openCodeRuntimeDeliveryStatusToRelayResult(status);
}
} catch (error) {
const reason = getErrorMessage(error);
logger.warn(
`OpenCode runtime delivery status after UI timeout failed for teammate "${input.memberName}": ${reason}`
);
return buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult([
`${OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON}: status lookup failed: ${reason}`,
]);
}
return buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult();
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
function openCodeRuntimeDeliveryStatusToRelayResult(
status: OpenCodeRuntimeDeliveryStatus
): OpenCodeMemberInboxRelayResult {
const lastDelivery: OpenCodeMemberInboxDelivery = {
delivered: status.delivered,
...(typeof status.responsePending === 'boolean'
? { responsePending: status.responsePending }
: {}),
...(typeof status.acceptanceUnknown === 'boolean'
? { acceptanceUnknown: status.acceptanceUnknown }
: {}),
...(status.responseState ? { responseState: status.responseState } : {}),
...(status.ledgerStatus ? { ledgerStatus: status.ledgerStatus } : {}),
...(status.visibleReplyMessageId
? { visibleReplyMessageId: status.visibleReplyMessageId }
: {}),
...(status.visibleReplyCorrelation
? { visibleReplyCorrelation: status.visibleReplyCorrelation }
: {}),
...(status.queuedBehindMessageId
? { queuedBehindMessageId: status.queuedBehindMessageId }
: {}),
...(status.reason ? { reason: status.reason } : {}),
...(status.diagnostics ? { diagnostics: status.diagnostics } : {}),
...(shouldPreserveOpenCodeRuntimeDeliveryStatusImpact(status)
? { userVisibleImpact: status.userVisibleImpact }
: {}),
};
return {
relayed: 0,
attempted: 1,
delivered: status.delivered && status.responsePending !== true ? 1 : 0,
failed: status.delivered ? 0 : 1,
lastDelivery,
diagnostics: status.diagnostics,
};
}
function shouldPreserveOpenCodeRuntimeDeliveryStatusImpact(
status: OpenCodeRuntimeDeliveryStatus
): boolean {
if (!status.userVisibleImpact) {
return false;
}
if (
status.userVisibleImpact.state === 'none' &&
(status.responsePending === true ||
status.acceptanceUnknown === true ||
Boolean(status.queuedBehindMessageId))
) {
return false;
}
return true;
}
function buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult(
extraDiagnostics: string[] = []
): OpenCodeMemberInboxRelayResult {
const diagnostics = [OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON, ...extraDiagnostics];
return {
relayed: 0,
attempted: 1,
delivered: 0,
failed: 1,
lastDelivery: {
delivered: true,
accepted: false,
responsePending: true,
acceptanceUnknown: true,
responseState: 'not_observed',
reason: OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON,
diagnostics,
},
};
}
function noteHeavyTeamDataWorkerFallback(operation: string): void {
if (!app.isPackaged) {
return;
@ -2866,9 +3028,10 @@ async function handleSendMessage(
: leadName !== null && memberName === leadName;
const actionMode = payload.actionMode;
const recipientProviderId = !isLeadRecipient
? await provisioning.resolveRuntimeRecipientProviderId(tn, memberName)
: undefined;
const recipientProviderId = await provisioning.resolveRuntimeRecipientProviderId(
tn,
memberName
);
const isOpenCodeRecipient = recipientProviderId === 'opencode';
// Attachments are routed through explicit provider transports only.
@ -2889,7 +3052,7 @@ async function handleSendMessage(
}
// Smart routing: lead + alive → stdin direct, else → inbox
if (isLeadRecipient && isAlive) {
if (isLeadRecipient && isAlive && !isOpenCodeRecipient) {
const resolvedLeadName = leadName ?? memberName;
const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName);
const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster);
@ -3083,8 +3246,12 @@ async function handleSendMessage(
// }
if (isOpenCodeRecipient) {
try {
const relay = await withTimeoutValue(
provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, {
const relay = await waitForOpenCodeRuntimeRelayForUi({
provisioning,
teamName: tn,
memberName,
messageId: result.messageId,
relayPromise: provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, {
onlyMessageId: result.messageId,
source: 'ui-send',
deliveryMetadata: {
@ -3093,23 +3260,7 @@ async function handleSendMessage(
taskRefs: validatedTaskRefs.value,
},
}),
OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS,
{
relayed: 0,
attempted: 1,
delivered: 0,
failed: 1,
lastDelivery: {
delivered: true,
accepted: false,
responsePending: true,
acceptanceUnknown: true,
responseState: 'not_observed',
reason: 'opencode_runtime_delivery_ui_timeout_pending',
diagnostics: ['opencode_runtime_delivery_ui_timeout_pending'],
},
}
);
});
const delivery = relay.lastDelivery ?? {
delivered: relay.relayed > 0,
reason: relay.relayed > 0 ? undefined : 'opencode_message_delivery_not_attempted',

View file

@ -11,7 +11,11 @@ import { tmpdir } from 'os';
import path from 'path';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
import {
buildProviderAwareCliEnv,
getAggregateProviderStatusStoredCredentialAllowlist,
getProviderStatusStoredCredentialAllowlist,
} from './providerAwareCliEnv';
import { providerConnectionService } from './ProviderConnectionService';
import type {
@ -839,12 +843,15 @@ export class ClaudeMultimodelBridgeService {
}
private async buildCliEnv(
binaryPath: string
binaryPath: string,
options: { allowedStoredApiKeyEnvVarNames?: readonly string[] } = {}
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
return buildProviderAwareCliEnv({
binaryPath,
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
allowedStoredApiKeyEnvVarNames: options.allowedStoredApiKeyEnvVarNames ?? [
'ANTHROPIC_AUTH_TOKEN',
],
});
}
@ -856,8 +863,7 @@ export class ClaudeMultimodelBridgeService {
binaryPath,
providerId,
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames:
providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined,
allowedStoredApiKeyEnvVarNames: getProviderStatusStoredCredentialAllowlist(providerId),
});
}
@ -1747,7 +1753,9 @@ export class ClaudeMultimodelBridgeService {
);
}
const { env, connectionIssues } = await this.buildCliEnv(binaryPath);
const { env, connectionIssues } = await this.buildCliEnv(binaryPath, {
allowedStoredApiKeyEnvVarNames: getAggregateProviderStatusStoredCredentialAllowlist(),
});
try {
const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], {

View file

@ -3,7 +3,10 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
import {
buildProviderAwareCliEnv,
getProviderStatusStoredCredentialAllowlist,
} from './providerAwareCliEnv';
import {
buildProviderModelProbeArgs,
classifyProviderModelProbeFailure,
@ -194,8 +197,9 @@ export class CliProviderModelAvailabilityService {
binaryPath: context.binaryPath,
providerId: context.provider.providerId,
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames:
context.provider.providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined,
allowedStoredApiKeyEnvVarNames: getProviderStatusStoredCredentialAllowlist(
context.provider.providerId
),
}).then((result) => ({
env: result.env,
providerArgs: result.providerArgs ?? [],

View file

@ -12,6 +12,14 @@ import type { CliProviderId, TeamProviderId } from '@shared/types';
type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined;
const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
const PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST = {
anthropic: ['ANTHROPIC_AUTH_TOKEN'],
codex: ['OPENAI_API_KEY'],
} as const;
const AGGREGATE_PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST = [
'ANTHROPIC_AUTH_TOKEN',
'OPENAI_API_KEY',
] as const;
export interface ProviderAwareCliEnvOptions {
binaryPath?: string | null;
@ -30,6 +38,20 @@ export interface ProviderAwareCliEnvResult {
providerArgs: string[];
}
export function getProviderStatusStoredCredentialAllowlist(
providerId: ProviderEnvTargetId
): readonly string[] | undefined {
if (providerId === 'anthropic' || providerId === 'codex') {
return PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST[providerId];
}
return undefined;
}
export function getAggregateProviderStatusStoredCredentialAllowlist(): readonly string[] {
return AGGREGATE_PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST;
}
function removeGlobalElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): void {
delete env[ELECTRON_RUN_AS_NODE_ENV];
}

View file

@ -55,15 +55,6 @@ function buildProviderFastModeArgs(config: ScheduleLaunchConfig): string[] {
}
function validateFastModeLaunchConfig(config: ScheduleLaunchConfig): void {
if (
config.providerId === 'codex' &&
config.fastMode === 'on' &&
config.resolvedFastMode !== true
) {
throw new Error(
'Codex Fast mode was requested for this schedule, but the saved launch profile is not Fast-eligible. Reopen the schedule and save it again with a supported ChatGPT account configuration.'
);
}
if (config.providerId !== 'codex' || config.resolvedFastMode !== true) {
return;
}

View file

@ -139,6 +139,9 @@ interface LedgerEvent {
sourceImportKey?: string;
evidenceProof?: string;
supersedesEventId?: string;
suppressed?: true;
suppressionReason?: string;
suppressedAt?: string;
snapshotId?: string;
snapshotSource?: string;
}
@ -1209,10 +1212,12 @@ export class TaskChangeLedgerReader {
events.forEach((event, index) => {
const sourceImportKey = this.sourceImportKeyForEvent(event);
if (!sourceImportKey) {
if (event.suppressed !== true) {
passthrough.push({ event, index });
}
return;
}
const rank = this.evidenceRankForEvent(event);
const rank = this.projectionRankForEvent(event);
const existing = selectedBySourceImportKey.get(sourceImportKey);
if (!existing || rank >= existing.rank) {
selectedBySourceImportKey.set(sourceImportKey, { event, index, rank });
@ -1221,7 +1226,9 @@ export class TaskChangeLedgerReader {
return [
...passthrough,
...[...selectedBySourceImportKey.values()].map(({ event, index }) => ({ event, index })),
...[...selectedBySourceImportKey.values()]
.filter(({ event }) => event.suppressed !== true)
.map(({ event, index }) => ({ event, index })),
]
.sort((left, right) => left.index - right.index)
.map(({ event }) => event);
@ -1241,6 +1248,10 @@ export class TaskChangeLedgerReader {
return null;
}
private projectionRankForEvent(event: LedgerEvent): number {
return event.suppressed === true ? Number.MAX_SAFE_INTEGER : this.evidenceRankForEvent(event);
}
private evidenceRankForEvent(event: LedgerEvent): number {
const hasFullText = this.hasFullTextEvidence(event);

View file

@ -6,9 +6,23 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import { withFileLock } from './fileLock';
import { withInboxLock } from './inboxLock';
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
import type { InboxMessage, SendMessageRequest, SendMessageResult, TaskRef } from '@shared/types';
export interface UpdateInboxMessageTextRequest {
member: string;
messageId: string;
text: string;
expectedMessageKind?: InboxMessage['messageKind'];
expectedWorkSyncPayloadHash?: string;
}
export interface UpdateInboxMessageTextResult {
found: boolean;
updated: boolean;
}
export interface MergeRuntimeDeliveryTaskRefsRequest {
inboxName: string;
messageId: string;
@ -137,6 +151,78 @@ export class TeamInboxWriter {
};
}
async updateMessageText(
teamName: string,
request: UpdateInboxMessageTextRequest
): Promise<UpdateInboxMessageTextResult> {
const messageId = request.messageId.trim();
if (!messageId) {
return { found: false, updated: false };
}
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`);
let result: UpdateInboxMessageTextResult = { found: false, updated: false };
await withFileLock(inboxPath, async () => {
await withInboxLock(inboxPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(inboxPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return;
}
if (!Array.isArray(parsed)) {
return;
}
let changed = false;
for (const item of parsed) {
if (!item || typeof item !== 'object') {
continue;
}
const row = item as Record<string, unknown>;
const rowMessageId = getEffectiveInboxMessageId(row);
if (rowMessageId !== messageId) {
continue;
}
result = { found: true, updated: changed };
if (request.expectedMessageKind && row.messageKind !== request.expectedMessageKind) {
continue;
}
if (
request.expectedWorkSyncPayloadHash &&
row.workSyncPayloadHash !== request.expectedWorkSyncPayloadHash
) {
continue;
}
if (row.text === request.text) {
continue;
}
row.text = request.text;
changed = true;
result = { found: true, updated: true };
}
if (!changed) {
return;
}
await atomicWriteAsync(inboxPath, JSON.stringify(parsed, null, 2));
});
});
return result;
}
async mergeRuntimeDeliveryTaskRefs(
teamName: string,
request: MergeRuntimeDeliveryTaskRefsRequest

View file

@ -712,24 +712,23 @@ export function createPersistedLaunchSnapshot(params: {
teamName: string;
expectedMembers: readonly string[];
bootstrapExpectedMembers?: readonly string[];
includeLeadMembers?: boolean;
leadSessionId?: string;
launchPhase?: PersistedTeamLaunchPhase;
members?: Record<string, PersistedTeamLaunchMemberState>;
updatedAt?: string;
}): PersistedTeamLaunchSnapshot {
const updatedAt = params.updatedAt ?? new Date().toISOString();
const shouldKeepExpectedMemberName = (name: string): boolean =>
name.length > 0 && name !== 'user' && (params.includeLeadMembers || !isLeadMember({ name }));
const expectedMembers = Array.from(
new Set(
params.expectedMembers
.map(normalizeMemberName)
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name }))
)
new Set(params.expectedMembers.map(normalizeMemberName).filter(shouldKeepExpectedMemberName))
);
const bootstrapExpectedMembers = Array.from(
new Set(
(params.bootstrapExpectedMembers ?? expectedMembers)
.map(normalizeMemberName)
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name }))
.filter(shouldKeepExpectedMemberName)
)
);
const members = params.members ?? {};

View file

@ -85,18 +85,21 @@ function resolveLeadName(config: TeamConfig): string {
return lead?.name?.trim() || 'team-lead';
}
function resolveSyntheticBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string {
function resolveSyntheticBootstrapTimestamp(
config: TeamConfig,
member: TeamConfigMember
): string | null {
const raw = member.joinedAt ?? (config as { createdAt?: unknown }).createdAt;
if (typeof raw === 'number' && Number.isFinite(raw)) {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
return new Date(raw).toISOString();
}
if (typeof raw === 'string') {
const parsed = Date.parse(raw);
if (Number.isFinite(parsed)) {
if (Number.isFinite(parsed) && parsed > 0) {
return new Date(parsed).toISOString();
}
}
return new Date(0).toISOString();
return null;
}
function buildSyntheticBootstrapDisplayPrompt(
@ -122,7 +125,10 @@ Call member_briefing directly yourself. Do NOT use Agent, any subagent, or a del
After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally.`;
}
function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] {
function buildSyntheticBootstrapMessages(
config: TeamConfig,
fallbackTimestampForMessage: (messageId: string) => string
): InboxMessage[] {
const members = Array.isArray(config.members) ? config.members : [];
const leadName = resolveLeadName(config);
const normalizedLeadName = leadName.trim().toLowerCase();
@ -134,15 +140,20 @@ function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] {
member.name.trim().toLowerCase() !== normalizedLeadName &&
member.removedAt == null
)
.map((member) => ({
.map((member) => {
const messageId = `bootstrap-start:${config.name}:${member.name}`;
return {
from: leadName,
to: member.name,
text: buildSyntheticBootstrapDisplayPrompt(config, member),
timestamp: resolveSyntheticBootstrapTimestamp(config, member),
timestamp:
resolveSyntheticBootstrapTimestamp(config, member) ??
fallbackTimestampForMessage(messageId),
read: true,
source: 'system_notification' as const,
messageId: `bootstrap-start:${config.name}:${member.name}`,
}));
messageId,
};
});
}
function isVisibleTeamMessage(message: InboxMessage): boolean {
@ -429,6 +440,7 @@ export class TeamMessageFeedService {
private readonly dirtyTeams = new Set<string>();
private readonly inFlightByTeam = new Map<string, InFlightTeamMessageFeed>();
private readonly generationByTeam = new Map<string, number>();
private readonly syntheticBootstrapTimestampByMessageId = new Map<string, string>();
constructor(private readonly deps: TeamMessageFeedDeps) {}
@ -487,6 +499,17 @@ export class TeamMessageFeedService {
return this.generationByTeam.get(teamName) ?? 0;
}
private getSyntheticBootstrapFallbackTimestamp(messageId: string): string {
const existing = this.syntheticBootstrapTimestampByMessageId.get(messageId);
if (existing) {
return existing;
}
const timestamp = new Date(Date.now()).toISOString();
this.syntheticBootstrapTimestampByMessageId.set(messageId, timestamp);
return timestamp;
}
private refreshCleanExpiredCacheInBackground(
teamName: string,
cached: TeamMessageFeedCacheEntry,
@ -554,7 +577,9 @@ export class TeamMessageFeedService {
const sourceMs = Date.now() - sourceStartedAt;
const normalizeStartedAt = Date.now();
const syntheticMessages = buildSyntheticBootstrapMessages(config);
const syntheticMessages = buildSyntheticBootstrapMessages(config, (messageId) =>
this.getSyntheticBootstrapFallbackTimestamp(messageId)
);
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter(
isVisibleTeamMessage
);

File diff suppressed because it is too large Load diff

View file

@ -48,6 +48,11 @@ const MANAGED_ENV_IDENTITY_MARKERS = [
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=',
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=',
] as const;
const MANAGED_INLINE_OPENCODE_CONFIG_PATTERNS = [
/OPENCODE_CONFIG_CONTENT=[\s\S]*"mcp"\s*:\s*\{[\s\S]*"agent-teams(?:-runtime-\d+)?"/i,
/OPENCODE_CONFIG_CONTENT=[\s\S]*"claude-multimodel runtime orchestration"/i,
/OPENCODE_CONFIG_CONTENT=[\s\S]*"(?:agent-teams|agent_teams|mcp__agent-teams|mcp__agent_teams)_\*"/i,
] as const;
export async function cleanupManagedOpenCodeServeProcesses(
options: OpenCodeManagedHostProcessCleanupOptions
@ -204,7 +209,8 @@ export function isAppManagedWindowsOpenCodeServeCommand(command: string): boolea
export function isManagedOpenCodeServeProcessDetails(details: string): boolean {
return (
processDetailsIncludeMarkers(details, MANAGED_ENV_MARKERS) &&
MANAGED_ENV_IDENTITY_MARKERS.some((marker) => processDetailsIncludeMarker(details, marker))
(MANAGED_ENV_IDENTITY_MARKERS.some((marker) => processDetailsIncludeMarker(details, marker)) ||
MANAGED_INLINE_OPENCODE_CONFIG_PATTERNS.some((pattern) => pattern.test(details)))
);
}

View file

@ -0,0 +1,210 @@
import type {
TeamAgentRuntimeBackendType,
TeamAgentRuntimeLivenessKind,
TeamProviderId,
} from '@shared/types';
export const STALE_PROCESS_RUNTIME_METADATA_DIAGNOSTIC = 'persisted runtime pid is not alive';
const STALE_PROCESS_RUNTIME_METADATA_FIELDS = [
'runtimePid',
'bootstrapExpectedAfter',
'bootstrapProofToken',
'bootstrapRunId',
'bootstrapProofMode',
'bootstrapContextHash',
'bootstrapBriefingHash',
'bootstrapRuntimeEventsPath',
] as const;
export interface StaleProcessRuntimeMetadataCleanupCandidate {
memberName: string;
runtimePid: number;
processPaneId: string;
agentId?: string;
}
export interface StaleProcessRuntimeMetadataCleanupInput {
memberName: string;
providerId?: TeamProviderId | string;
backendType?: TeamAgentRuntimeBackendType | string;
agentId?: string;
tmuxPaneId?: string;
runtimePid?: number;
runtimeSessionId?: string;
runtimeRunId?: string;
laneId?: string;
laneKind?: string;
laneOwnerProviderId?: string;
livenessKind?: TeamAgentRuntimeLivenessKind | string;
runtimeDiagnostic?: string;
processTableAvailable: boolean;
isLead: boolean;
isRemoved: boolean;
}
export interface StaleProcessRuntimeMetadataCleanupResult {
member: Record<string, unknown>;
changed: boolean;
}
export interface StaleProcessRuntimeMetadataRuntimeGuard {
hasTrackedRun?: boolean;
hasRuntimeAdapterRun?: boolean;
hasSecondaryRuntimeRun?: boolean;
isStoppingSecondaryRuntimeTeam?: boolean;
hasLaunchStateStoreOperation?: boolean;
hasTeamOperationLock?: boolean;
hasActiveLaunchState?: boolean;
}
function normalizePositiveInteger(value: unknown): number | undefined {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function isOpenCodeProvider(providerId: unknown): boolean {
return normalizeString(providerId).toLowerCase() === 'opencode';
}
function hasRuntimeSessionId(value: unknown): boolean {
return normalizeString(value).length > 0;
}
function hasLaneRuntimeMetadata(value: {
laneId?: unknown;
laneKind?: unknown;
laneOwnerProviderId?: unknown;
}): boolean {
return (
normalizeString(value.laneId).length > 0 ||
normalizeString(value.laneKind).length > 0 ||
normalizeString(value.laneOwnerProviderId).length > 0
);
}
function isDirectProcessRuntimeMetadata(params: {
backendType?: unknown;
tmuxPaneId?: unknown;
runtimePid: number;
}): boolean {
const backendType = normalizeString(params.backendType).toLowerCase();
const tmuxPaneId = normalizeString(params.tmuxPaneId);
const processPaneId = `process:${params.runtimePid}`;
if (tmuxPaneId && tmuxPaneId !== processPaneId) {
return false;
}
return backendType === 'process' || tmuxPaneId === processPaneId;
}
export function hasDirectProcessRuntimeMetadataForStaleCleanup(params: {
backendType?: unknown;
tmuxPaneId?: unknown;
runtimePid?: unknown;
}): boolean {
const runtimePid = normalizePositiveInteger(params.runtimePid);
return runtimePid != null
? isDirectProcessRuntimeMetadata({
backendType: params.backendType,
tmuxPaneId: params.tmuxPaneId,
runtimePid,
})
: false;
}
export function shouldSkipStaleProcessRuntimeMetadataCleanupForRuntimeGuard(
input: StaleProcessRuntimeMetadataRuntimeGuard
): boolean {
return Boolean(
input.hasTrackedRun ||
input.hasRuntimeAdapterRun ||
input.hasSecondaryRuntimeRun ||
input.isStoppingSecondaryRuntimeTeam ||
input.hasLaunchStateStoreOperation ||
input.hasTeamOperationLock ||
input.hasActiveLaunchState
);
}
export function collectStaleProcessRuntimeMetadataCleanupCandidate(
input: StaleProcessRuntimeMetadataCleanupInput
): StaleProcessRuntimeMetadataCleanupCandidate | null {
const memberName = input.memberName.trim();
const runtimePid = normalizePositiveInteger(input.runtimePid);
if (!memberName || runtimePid == null) {
return null;
}
if (input.isLead || input.isRemoved) {
return null;
}
if (input.livenessKind !== 'stale_metadata') {
return null;
}
if (input.runtimeDiagnostic !== STALE_PROCESS_RUNTIME_METADATA_DIAGNOSTIC) {
return null;
}
if (!input.processTableAvailable) {
return null;
}
if (
isOpenCodeProvider(input.providerId) ||
hasRuntimeSessionId(input.runtimeSessionId) ||
hasLaneRuntimeMetadata(input)
) {
return null;
}
if (
!isDirectProcessRuntimeMetadata({
backendType: input.backendType,
tmuxPaneId: input.tmuxPaneId,
runtimePid,
})
) {
return null;
}
return {
memberName,
runtimePid,
processPaneId: `process:${runtimePid}`,
...(input.agentId?.trim() ? { agentId: input.agentId.trim() } : {}),
};
}
export function clearStaleProcessRuntimeMetadataFromMember(
member: Record<string, unknown>,
candidate: StaleProcessRuntimeMetadataCleanupCandidate
): StaleProcessRuntimeMetadataCleanupResult {
const runtimePid = normalizePositiveInteger(member.runtimePid);
if (runtimePid == null || runtimePid !== candidate.runtimePid) {
return { member: { ...member }, changed: false };
}
if (isOpenCodeProvider(member.providerId ?? member.provider)) {
return { member: { ...member }, changed: false };
}
if (hasRuntimeSessionId(member.runtimeSessionId) || hasLaneRuntimeMetadata(member)) {
return { member: { ...member }, changed: false };
}
if (
!isDirectProcessRuntimeMetadata({
backendType: member.backendType,
tmuxPaneId: member.tmuxPaneId,
runtimePid,
})
) {
return { member: { ...member }, changed: false };
}
const next = { ...member };
for (const field of STALE_PROCESS_RUNTIME_METADATA_FIELDS) {
delete next[field];
}
if (normalizeString(member.tmuxPaneId) === candidate.processPaneId) {
delete next.tmuxPaneId;
}
return { member: next, changed: true };
}

View file

@ -600,6 +600,10 @@ export function isBootstrapMemberEvidenceCurrentForMember(
typeof current.runtimeRunId === 'string' ? current.runtimeRunId.trim() : '';
const bootstrapRuntimeRunId =
typeof bootstrapMember.runtimeRunId === 'string' ? bootstrapMember.runtimeRunId.trim() : '';
const hasSameRuntimeRunId =
currentRuntimeRunId.length > 0 &&
bootstrapRuntimeRunId.length > 0 &&
currentRuntimeRunId === bootstrapRuntimeRunId;
if (
currentRuntimeRunId.length > 0 &&
bootstrapRuntimeRunId.length > 0 &&
@ -631,10 +635,18 @@ export function isBootstrapMemberEvidenceCurrentForMember(
const hasDurableSpawnBoundary =
Number.isFinite(firstSpawnAcceptedMs) &&
(!Number.isFinite(lastEvaluatedMs) || firstSpawnAcceptedMs <= lastEvaluatedMs);
const boundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN;
const hasCompatibleRuntimeRunIdForSkew =
currentRuntimeRunId.length === 0 ||
(bootstrapRuntimeRunId.length > 0 && currentRuntimeRunId === bootstrapRuntimeRunId);
const currentBoundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN;
const sameRunBootstrapBoundaryMs =
evidenceKind === 'confirmation' && hasSameRuntimeRunId && hasDurableBootstrapSpawnAcceptedAt
? bootstrapFirstSpawnAcceptedMs
: NaN;
const boundaryMs =
Number.isFinite(currentBoundaryMs) && Number.isFinite(sameRunBootstrapBoundaryMs)
? Math.min(currentBoundaryMs, sameRunBootstrapBoundaryMs)
: Number.isFinite(currentBoundaryMs)
? currentBoundaryMs
: sameRunBootstrapBoundaryMs;
const hasCompatibleRuntimeRunIdForSkew = currentRuntimeRunId.length === 0 || hasSameRuntimeRunId;
const withinBootstrapConfirmationClockSkew =
evidenceKind === 'confirmation' &&
Number.isFinite(boundaryMs) &&

View file

@ -0,0 +1,341 @@
import { describe, expect, it } from 'vitest';
import { resolveTeamMemberRuntimeLiveness } from '../../TeamRuntimeLivenessResolver';
import {
clearStaleProcessRuntimeMetadataFromMember,
collectStaleProcessRuntimeMetadataCleanupCandidate,
hasDirectProcessRuntimeMetadataForStaleCleanup,
shouldSkipStaleProcessRuntimeMetadataCleanupForRuntimeGuard,
STALE_PROCESS_RUNTIME_METADATA_DIAGNOSTIC,
} from '../StaleProcessRuntimeMetadataCleanup';
import type { RuntimeProcessTableRow } from '@features/tmux-installer/main';
const baseCandidateInput = {
memberName: 'tom',
providerId: 'codex',
backendType: 'process',
agentId: 'tom@signal-ops-2',
tmuxPaneId: 'process:37749',
runtimePid: 37749,
runtimeSessionId: undefined,
livenessKind: 'stale_metadata',
runtimeDiagnostic: STALE_PROCESS_RUNTIME_METADATA_DIAGNOSTIC,
processTableAvailable: true,
isLead: false,
isRemoved: false,
} as const;
function createRuntimeMember(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
name: 'tom',
agentId: 'tom@signal-ops-2',
provider: 'codex',
providerId: 'codex',
model: 'gpt-5.5',
role: 'developer',
prompt: 'Build things',
color: 'yellow',
cwd: '/repo',
subscriptions: ['team-lead'],
backendType: 'process',
tmuxPaneId: 'process:37749',
runtimePid: 37749,
bootstrapExpectedAfter: '2026-05-16T18:35:52.562Z',
bootstrapProofToken: 'token',
bootstrapRunId: 'run-1',
bootstrapProofMode: 'native_app_managed_context',
bootstrapContextHash: 'context-hash',
bootstrapBriefingHash: 'briefing-hash',
bootstrapRuntimeEventsPath: '/repo/.agent-teams/tom.runtime.jsonl',
...overrides,
};
}
describe('stale process runtime metadata cleanup planner', () => {
it('clears only stale direct-process runtime fields and preserves member identity', () => {
const candidate = collectStaleProcessRuntimeMetadataCleanupCandidate(baseCandidateInput);
expect(candidate).toEqual({
memberName: 'tom',
runtimePid: 37749,
processPaneId: 'process:37749',
agentId: 'tom@signal-ops-2',
});
const result = clearStaleProcessRuntimeMetadataFromMember(createRuntimeMember(), candidate!);
expect(result.changed).toBe(true);
expect(result.member).toMatchObject({
name: 'tom',
agentId: 'tom@signal-ops-2',
provider: 'codex',
providerId: 'codex',
model: 'gpt-5.5',
role: 'developer',
prompt: 'Build things',
color: 'yellow',
cwd: '/repo',
subscriptions: ['team-lead'],
backendType: 'process',
});
expect(result.member.runtimePid).toBeUndefined();
expect(result.member.tmuxPaneId).toBeUndefined();
expect(result.member.bootstrapRunId).toBeUndefined();
expect(result.member.bootstrapRuntimeEventsPath).toBeUndefined();
});
it('skips OpenCode members', () => {
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
providerId: 'opencode',
})
).toBeNull();
const candidate = collectStaleProcessRuntimeMetadataCleanupCandidate(baseCandidateInput)!;
const result = clearStaleProcessRuntimeMetadataFromMember(
createRuntimeMember({ providerId: 'opencode', provider: 'opencode' }),
candidate
);
expect(result.changed).toBe(false);
expect(result.member.runtimePid).toBe(37749);
});
it('skips normal tmux pane metadata', () => {
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
backendType: 'tmux',
tmuxPaneId: '%12',
})
).toBeNull();
});
it('skips mismatched process pane metadata', () => {
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
tmuxPaneId: 'process:99999',
})
).toBeNull();
});
it('matches only direct-process runtime metadata shapes', () => {
expect(
hasDirectProcessRuntimeMetadataForStaleCleanup({
backendType: 'process',
tmuxPaneId: 'process:37749',
runtimePid: 37749,
})
).toBe(true);
expect(
hasDirectProcessRuntimeMetadataForStaleCleanup({
backendType: 'process',
tmuxPaneId: 'process:99999',
runtimePid: 37749,
})
).toBe(false);
expect(
hasDirectProcessRuntimeMetadataForStaleCleanup({
backendType: 'tmux',
tmuxPaneId: '%12',
runtimePid: 37749,
})
).toBe(false);
});
it('skips active or uncertain cleanup guards', () => {
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
processTableAvailable: false,
})
).toBeNull();
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
isLead: true,
})
).toBeNull();
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
isRemoved: true,
})
).toBeNull();
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
runtimeSessionId: 'session-1',
})
).toBeNull();
expect(
shouldSkipStaleProcessRuntimeMetadataCleanupForRuntimeGuard({ hasTrackedRun: true })
).toBe(true);
expect(
shouldSkipStaleProcessRuntimeMetadataCleanupForRuntimeGuard({
hasRuntimeAdapterRun: true,
})
).toBe(true);
expect(
shouldSkipStaleProcessRuntimeMetadataCleanupForRuntimeGuard({
hasActiveLaunchState: true,
})
).toBe(true);
expect(shouldSkipStaleProcessRuntimeMetadataCleanupForRuntimeGuard({})).toBe(false);
});
it('skips lane metadata but allows direct bootstrap run ids', () => {
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
laneId: 'secondary:bob',
})
).toBeNull();
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
runtimeRunId: 'bootstrap-run-1',
})?.runtimePid
).toBe(37749);
const candidate = collectStaleProcessRuntimeMetadataCleanupCandidate(baseCandidateInput)!;
const result = clearStaleProcessRuntimeMetadataFromMember(
createRuntimeMember({ laneId: 'secondary:bob' }),
candidate
);
expect(result.changed).toBe(false);
expect(result.member.runtimePid).toBe(37749);
});
it('does not clear if the process table shows the same pid is alive', () => {
const candidate = collectStaleProcessRuntimeMetadataCleanupCandidate(baseCandidateInput)!;
const processRows: RuntimeProcessTableRow[] = [
{ pid: 37749, ppid: 1, command: 'node some-other-process.js' },
];
const processStillExists = processRows.some((row) => row.pid === candidate.runtimePid);
expect(processStillExists).toBe(true);
});
});
describe('stale process runtime metadata cleanup runtime flow', () => {
it('plans cleanup for stale metadata when process table is available and no pid exists', () => {
const resolved = resolveTeamMemberRuntimeLiveness({
teamName: 'signal-ops-2',
memberName: 'tom',
agentId: 'tom@signal-ops-2',
providerId: 'codex',
backendType: 'process',
tmuxPaneId: 'process:37749',
persistedRuntimePid: 37749,
processRows: [],
processTableAvailable: true,
nowIso: '2026-05-28T00:00:00.000Z',
});
const candidate = collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
livenessKind: resolved.livenessKind,
runtimeDiagnostic: resolved.runtimeDiagnostic,
runtimePid: resolved.pid,
});
expect(resolved.livenessKind).toBe('stale_metadata');
expect(candidate?.runtimePid).toBe(37749);
});
it('does not plan cleanup for registered-only metadata', () => {
const resolved = resolveTeamMemberRuntimeLiveness({
teamName: 'signal-ops-2',
memberName: 'bob',
agentId: 'bob@signal-ops-2',
providerId: 'opencode',
backendType: 'process',
processRows: [],
processTableAvailable: true,
nowIso: '2026-05-28T00:00:00.000Z',
});
expect(resolved.livenessKind).toBe('registered_only');
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
memberName: 'bob',
providerId: 'opencode',
runtimePid: undefined,
livenessKind: resolved.livenessKind,
runtimeDiagnostic: resolved.runtimeDiagnostic,
})
).toBeNull();
});
it('does not plan cleanup for verified runtime process evidence', () => {
const resolved = resolveTeamMemberRuntimeLiveness({
teamName: 'signal-ops-2',
memberName: 'tom',
agentId: 'tom@signal-ops-2',
providerId: 'codex',
backendType: 'process',
persistedRuntimePid: 37749,
processRows: [
{
pid: 55555,
ppid: 1,
command: 'node runtime.js --team-name signal-ops-2 --agent-id tom@signal-ops-2',
},
],
processTableAvailable: true,
nowIso: '2026-05-28T00:00:00.000Z',
});
expect(resolved.livenessKind).toBe('runtime_process');
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
runtimePid: resolved.pid,
livenessKind: resolved.livenessKind,
runtimeDiagnostic: resolved.runtimeDiagnostic,
})
).toBeNull();
});
it('does not plan cleanup for confirmed bootstrap evidence', () => {
const resolved = resolveTeamMemberRuntimeLiveness({
teamName: 'signal-ops-2',
memberName: 'alice',
agentId: 'alice@signal-ops-2',
providerId: 'anthropic',
backendType: 'process',
trackedSpawnStatus: {
status: 'online',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
updatedAt: '2026-05-28T00:00:00.000Z',
},
processRows: [],
processTableAvailable: true,
nowIso: '2026-05-28T00:00:00.000Z',
});
expect(resolved.livenessKind).toBe('confirmed_bootstrap');
expect(
collectStaleProcessRuntimeMetadataCleanupCandidate({
...baseCandidateInput,
memberName: 'alice',
providerId: 'anthropic',
runtimePid: undefined,
livenessKind: resolved.livenessKind,
runtimeDiagnostic: resolved.runtimeDiagnostic,
})
).toBeNull();
});
});

View file

@ -1116,10 +1116,19 @@ function buildMemberBootstrapPrompt(
const teamPrompt = input.prompt?.trim();
const role = member.role?.trim() || member.workflow?.trim() || 'teammate';
const workflow = member.workflow?.trim();
const isTeamLead =
member.name.trim().toLowerCase() === 'team-lead' || role.trim().toLowerCase() === 'team lead';
const identityLine = isTeamLead
? `You are ${member.name}, the team lead for team "${input.teamName}".`
: `You are ${member.name}, a ${role} on team "${input.teamName}".`;
const messageTargets = isTeamLead
? 'the human user or a teammate'
: 'the human user, team lead, or another teammate';
const senderRole = isTeamLead ? 'team lead' : 'OpenCode teammate';
return [
'<agent_teams_app_managed_bootstrap_briefing>',
'AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1',
`You are ${member.name}, a ${role} on team "${input.teamName}".`,
identityLine,
teamPrompt ? `Team launch context:\n${teamPrompt}` : null,
workflow ? `Workflow:\n${workflow}` : null,
'',
@ -1132,8 +1141,8 @@ function buildMemberBootstrapPrompt(
'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.',
'If the briefing says there are no actionable tasks, stay idle silently.',
'',
'When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.',
`Always set from="${member.name}" when sending a team message from this OpenCode teammate.`,
`When you need to message ${messageTargets}, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.`,
`Always set from="${member.name}" when sending a team message from this ${senderRole}.`,
'Do not answer team/app messages only as plain assistant text when agent-teams_message_send is available.',
'</agent_teams_app_managed_bootstrap_briefing>',
]

View file

@ -2,6 +2,10 @@ import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction
import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
import {
inferTeamProviderIdFromModel,
normalizeOptionalTeamProviderId,
} from '@shared/utils/teamProvider';
import { TeamConfigReader } from '../../TeamConfigReader';
import { TeamMembersMetaStore } from '../../TeamMembersMetaStore';
@ -36,6 +40,8 @@ import type {
BoardTaskLogSegment,
BoardTaskLogStreamResponse,
BoardTaskLogStreamSummary,
TeamMember,
TeamProviderId,
TeamTask,
} from '@shared/types';
@ -104,6 +110,58 @@ function normalizeMemberName(value: string): string {
return value.trim().toLowerCase();
}
function resolveExplicitMemberProviderId(
member: TeamMember | undefined
): TeamProviderId | undefined {
if (!member) {
return undefined;
}
const legacyProvider = (member as { provider?: unknown }).provider;
return (
normalizeOptionalTeamProviderId(member.providerId) ??
normalizeOptionalTeamProviderId(legacyProvider)
);
}
function inferProviderIdFromMemberModel(
member: TeamMember | undefined
): TeamProviderId | undefined {
return inferTeamProviderIdFromModel(member?.model);
}
function inferProviderIdFromBackend(providerBackendId: unknown): TeamProviderId | undefined {
const normalized = typeof providerBackendId === 'string' ? providerBackendId.trim() : '';
if (normalized === 'codex-native') {
return 'codex';
}
if (normalized === 'opencode-cli') {
return 'opencode';
}
return undefined;
}
function resolveProviderFromMemberSources(input: {
configMembers: readonly TeamMember[];
metaMembers: readonly TeamMember[];
memberName: string;
}): TeamProviderId | undefined {
const normalizedMemberName = normalizeMemberName(input.memberName);
const configMember = input.configMembers.find(
(candidate) => normalizeMemberName(candidate.name) === normalizedMemberName
);
const metaMember = input.metaMembers.find(
(candidate) => normalizeMemberName(candidate.name) === normalizedMemberName
);
return (
resolveExplicitMemberProviderId(metaMember) ??
resolveExplicitMemberProviderId(configMember) ??
inferProviderIdFromBackend(configMember?.providerBackendId) ??
inferProviderIdFromMemberModel(configMember) ??
inferProviderIdFromBackend(metaMember?.providerBackendId) ??
inferProviderIdFromMemberModel(metaMember)
);
}
const isBoardMcpToolName = isBoardTaskLogMcpToolName;
const canonicalizeBoardToolName = canonicalizeBoardTaskLogToolName;
@ -2260,10 +2318,13 @@ export class BoardTaskLogStreamService {
return false;
}
const member = [...metaMembers, ...(config?.members ?? [])].find(
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner
return (
resolveProviderFromMemberSources({
configMembers: config?.members ?? [],
metaMembers,
memberName: normalizedOwner,
}) === 'opencode'
);
return member?.providerId === 'opencode';
} catch {
return false;
}

View file

@ -1,4 +1,8 @@
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
import {
inferTeamProviderIdFromModel,
normalizeOptionalTeamProviderId,
} from '@shared/utils/teamProvider';
import { TeamConfigReader } from '../../TeamConfigReader';
import { TeamMembersMetaStore } from '../../TeamMembersMetaStore';
@ -14,6 +18,7 @@ import type {
BoardTaskLogParticipant,
BoardTaskLogSegment,
BoardTaskLogStreamResponse,
TeamProviderId,
TeamTask,
} from '@shared/types';
@ -44,6 +49,31 @@ function buildActor(memberName: string, sessionId: string): BoardTaskLogActor {
};
}
function resolveExplicitProviderId(member: {
providerId?: unknown;
provider?: unknown;
}): ReturnType<typeof normalizeOptionalTeamProviderId> {
return (
normalizeOptionalTeamProviderId(member.providerId) ??
normalizeOptionalTeamProviderId(member.provider)
);
}
function inferProviderIdFromMemberModel(member: { model?: string } | undefined) {
return inferTeamProviderIdFromModel(member?.model);
}
function inferProviderIdFromBackend(providerBackendId: unknown): TeamProviderId | undefined {
const normalized = typeof providerBackendId === 'string' ? providerBackendId.trim() : '';
if (normalized === 'codex-native') {
return 'codex';
}
if (normalized === 'opencode-cli') {
return 'opencode';
}
return undefined;
}
export class CodexNativeTaskLogStreamSource {
constructor(
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
@ -171,9 +201,19 @@ export class CodexNativeTaskLogStreamSource {
this.membersMetaStore.getMembers(teamName).catch(() => []),
this.readConfigForObservation(teamName).catch(() => null),
]);
const member = [...metaMembers, ...(config?.members ?? [])].find(
const configMember = (config?.members ?? []).find(
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner
) as { providerId?: string } | undefined;
return member?.providerId === 'codex';
);
const metaMember = metaMembers.find(
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner
);
const providerId =
resolveExplicitProviderId(metaMember ?? {}) ??
resolveExplicitProviderId(configMember ?? {}) ??
inferProviderIdFromBackend(configMember?.providerBackendId) ??
inferProviderIdFromMemberModel(configMember) ??
inferProviderIdFromBackend(metaMember?.providerBackendId) ??
inferProviderIdFromMemberModel(metaMember);
return providerId === 'codex';
}
}

View file

@ -1,9 +1,7 @@
import {
type ChildProcess,
exec,
execFile,
type ExecFileOptions,
type ExecOptions,
spawn,
type SpawnOptions,
spawnSync,
@ -80,65 +78,14 @@ function execFileAsync(
}
/**
* Promise wrapper for exec. Used exclusively as a Windows shell fallback
* when execFile fails with EINVAL on non-ASCII binary paths. The command
* string is built from a known binary path + args, NOT from user input.
* cmd.exe fallback implemented through execFile so Node does not invoke an
* additional shell around the guarded command string.
*/
function execShellAsync(
cmd: string,
options: ExecOptions = {}
options: ExecFileOptions = {}
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const { timeout, killSignal, ...execOptions } = options;
const timeoutMs = typeof timeout === 'number' && timeout > 0 ? timeout : 0;
const timeoutSignal = normalizeKillSignal(killSignal);
let child: ChildProcess | null = null;
let settled = false;
let stdoutText = '';
let stderrText = '';
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
// eslint-disable-next-line sonarjs/os-command, security/detect-child-process -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
child = exec(cmd, execOptions, (err, stdout, stderr) => {
if (settled) {
return;
}
settled = true;
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
if (err)
reject(
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error')
);
else resolve({ stdout: String(stdout), stderr: String(stderr) });
});
if (!settled) {
trackCliProcess(child);
if (timeoutMs > 0) {
child.stdout?.on('data', (chunk: Buffer | string) => {
stdoutText += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer | string) => {
stderrText += chunk.toString();
});
timeoutHandle = setTimeout(() => {
if (settled) {
return;
}
settled = true;
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
killProcessTree(child, timeoutSignal);
const error = new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`);
Object.assign(error, {
killed: true,
signal: timeoutSignal,
stdout: stdoutText,
stderr: stderrText,
});
reject(error);
}, timeoutMs);
timeoutHandle.unref?.();
}
}
});
return execFileAsync(getWindowsCmdPath(), ['/d', '/s', '/c', cmd], options);
}
function cleanupTimedCliProcess(
@ -300,6 +247,43 @@ function quoteArg(arg: string): string {
return quoteWindowsCmdArg(arg);
}
function containsWindowsShellUnsafeControlChar(part: string): boolean {
for (let index = 0; index < part.length; index += 1) {
const code = part.charCodeAt(index);
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) {
return true;
}
}
return false;
}
function assertSafeWindowsShellFallbackPart(part: string): void {
if (containsWindowsShellUnsafeControlChar(part)) {
throw new Error('Unsafe Windows shell fallback argument: control characters are not allowed');
}
}
function buildWindowsShellFallbackCommand(parts: string[]): string {
for (const part of parts) {
assertSafeWindowsShellFallbackPart(part);
}
return parts.map(quoteArg).join(' ');
}
function getWindowsCmdPath(): string {
return path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe');
}
function spawnWindowsShellFallback(
cmd: string,
options: ReturnType<typeof withCliProcessDefaults<SpawnOptions>>
): ReturnType<typeof spawn> {
return spawn(getWindowsCmdPath(), ['/d', '/s', '/c', cmd], {
...options,
shell: false,
});
}
/** Env vars injected into every spawned Claude CLI process. */
const CLI_ENV_DEFAULTS: Record<string, string> = {
CLAUDE_HOOK_JUDGE_MODE: 'true',
@ -408,8 +392,8 @@ export async function execCli(
}
// shell fallback (Windows only; others shouldn't reach here)
const cmd = [target, ...args].map(quoteArg).join(' ');
const shellResult = await execShellAsync(cmd, opts as unknown as ExecOptions);
const cmd = buildWindowsShellFallbackCommand([target, ...args]);
const shellResult = await execShellAsync(cmd, opts);
return { stdout: String(shellResult.stdout), stderr: String(shellResult.stderr) };
}
@ -435,9 +419,8 @@ export function spawnCli(
}
if (process.platform === 'win32' && needsShell(binaryPath)) {
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
return trackCliProcess(spawn(cmd, { ...opts, shell: true }));
const cmd = buildWindowsShellFallbackCommand([binaryPath, ...args]);
return trackCliProcess(spawnWindowsShellFallback(cmd, opts));
}
try {
@ -446,9 +429,8 @@ export function spawnCli(
const code =
err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined;
if (process.platform === 'win32' && code === 'EINVAL') {
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
return trackCliProcess(spawn(cmd, { ...opts, shell: true }));
const cmd = buildWindowsShellFallbackCommand([binaryPath, ...args]);
return trackCliProcess(spawnWindowsShellFallback(cmd, opts));
}
throw err;
}

View file

@ -43,7 +43,10 @@ import {
shouldShowProviderStatusSkeleton,
} from '@renderer/components/runtime/providerConnectionUi';
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import {
buildProviderRuntimeBackendSummaryText,
getProviderRuntimeBackendSummary,
} from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import {
getProviderTerminalCommand,
getProviderTerminalLogoutCommand,
@ -827,6 +830,11 @@ const InstalledBanner = ({
}: InstalledBannerProps): React.JSX.Element => {
const { t } = useAppTranslation('dashboard');
const { t: settingsT } = useAppTranslation('settings');
const { t: commonT } = useAppTranslation('common');
const runtimeBackendSummaryText = useMemo(
() => buildProviderRuntimeBackendSummaryText(commonT),
[commonT]
);
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
const styles = VARIANT_STYLES[variant];
const visibleProviders = useMemo(
@ -954,7 +962,7 @@ const InstalledBanner = ({
const actionDisabled = isBusy || !cliStatus.binaryPath;
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
? getProviderCurrentRuntimeSummary(provider, settingsT)
: getProviderRuntimeBackendSummary(provider);
: getProviderRuntimeBackendSummary(provider, runtimeBackendSummaryText);
const connectionModeSummary = getProviderConnectionModeSummary(provider, settingsT);
const credentialSummary = getProviderCredentialSummary(provider, settingsT);
const dashboardRateLimits = getDashboardRateLimitsForProvider(provider);

View file

@ -95,7 +95,9 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme
)}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? 'Copied!' : 'Copy env var name'}</TooltipContent>
<TooltipContent>
{copied ? t('apiKeys.actions.copied') : t('apiKeys.actions.copyEnvVarName')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@ -135,7 +137,9 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme
<Trash2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{confirmDelete ? 'Click again to confirm' : 'Delete'}</TooltipContent>
<TooltipContent>
{confirmDelete ? t('apiKeys.actions.confirmDelete') : t('apiKeys.actions.delete')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>

View file

@ -16,6 +16,52 @@ interface Props {
onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void;
}
export interface ProviderRuntimeBackendSummaryText {
auto: string;
autoCurrently: (backend: string) => string;
audienceInternal: string;
states: {
locked: string;
disabled: string;
authRequired: string;
runtimeMissing: string;
degraded: string;
unavailable: string;
};
}
export function buildProviderRuntimeBackendSummaryText(
t: ReturnType<typeof useAppTranslation>['t']
): ProviderRuntimeBackendSummaryText {
return {
auto: t('runtimeBackendSelector.auto'),
autoCurrently: (backend) => t('runtimeBackendSelector.autoCurrently', { backend }),
audienceInternal: t('runtimeBackendSelector.audience.internal'),
states: {
locked: t('runtimeBackendSelector.states.locked'),
disabled: t('runtimeBackendSelector.states.disabled'),
authRequired: t('runtimeBackendSelector.states.authRequired'),
runtimeMissing: t('runtimeBackendSelector.states.runtimeMissing'),
degraded: t('runtimeBackendSelector.states.degraded'),
unavailable: t('runtimeBackendSelector.states.unavailable'),
},
};
}
const DEFAULT_SUMMARY_TEXT: ProviderRuntimeBackendSummaryText = {
auto: 'Auto',
autoCurrently: (backend) => `Auto (currently: ${backend})`,
audienceInternal: 'Internal',
states: {
locked: 'Locked',
disabled: 'Disabled',
authRequired: 'Auth required',
runtimeMissing: 'Runtime missing',
degraded: 'Degraded',
unavailable: 'Unavailable',
},
};
export function getProviderRuntimeBackendStateLabel(
option: NonNullable<CliProviderStatus['availableBackends']>[number]
): string | null {
@ -78,7 +124,47 @@ export function getOptionDisplayLabel(
return 'Auto';
}
export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): string | null {
function getOptionSummaryDisplayLabel(
provider: CliProviderStatus,
option: NonNullable<CliProviderStatus['availableBackends']>[number],
resolvedOption: NonNullable<CliProviderStatus['availableBackends']>[number] | null,
text: ProviderRuntimeBackendSummaryText
): string {
if (option.id !== 'auto') {
return getOptionDisplayLabel(provider, option, resolvedOption);
}
if (resolvedOption?.label) {
return text.autoCurrently(resolvedOption.label);
}
return text.auto;
}
function getProviderRuntimeBackendStateSummaryLabel(
option: NonNullable<CliProviderStatus['availableBackends']>[number],
text: ProviderRuntimeBackendSummaryText
): string | null {
switch (getProviderRuntimeBackendStateLabel(option)) {
case 'Locked':
return text.states.locked;
case 'Disabled':
return text.states.disabled;
case 'Auth required':
return text.states.authRequired;
case 'Runtime missing':
return text.states.runtimeMissing;
case 'Degraded':
return text.states.degraded;
case 'Unavailable':
return text.states.unavailable;
default:
return null;
}
}
export function getProviderRuntimeBackendSummary(
provider: CliProviderStatus,
text: ProviderRuntimeBackendSummaryText = DEFAULT_SUMMARY_TEXT
): string | null {
const options = provider.availableBackends ?? [];
if (options.length === 0) {
return null;
@ -87,9 +173,11 @@ export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): s
const selectedBackendId = provider.selectedBackendId ?? options[0]?.id ?? '';
const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0];
const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null;
const parts = [getOptionDisplayLabel(provider, selectedOption, resolvedOption)];
const audienceLabel = getProviderRuntimeBackendAudienceLabel(selectedOption);
const stateLabel = getProviderRuntimeBackendStateLabel(selectedOption);
const parts = [getOptionSummaryDisplayLabel(provider, selectedOption, resolvedOption, text)];
const audienceLabel = getProviderRuntimeBackendAudienceLabel(selectedOption)
? text.audienceInternal
: null;
const stateLabel = getProviderRuntimeBackendStateSummaryLabel(selectedOption, text);
if (audienceLabel) {
parts.push(audienceLabel.toLowerCase());
@ -107,6 +195,7 @@ export const ProviderRuntimeBackendSelector = ({
onSelect,
}: Props): React.JSX.Element | null => {
const { t } = useAppTranslation('common');
const summaryText = buildProviderRuntimeBackendSummaryText(t);
const options = getVisibleProviderRuntimeBackendOptions(provider);
if (options.length === 0) {
return null;
@ -150,9 +239,9 @@ export const ProviderRuntimeBackendSelector = ({
): string => {
if (option.id === 'auto') {
if (resolvedOption?.label) {
return t('runtimeBackendSelector.autoCurrently', { backend: resolvedOption.label });
return summaryText.autoCurrently(resolvedOption.label);
}
return t('runtimeBackendSelector.auto');
return summaryText.auto;
}
return getOptionDisplayLabel(provider, option, resolvedOption);
};

View file

@ -61,6 +61,7 @@ import {
isConnectionManagedRuntimeProvider,
} from './providerConnectionUi';
import {
buildProviderRuntimeBackendSummaryText,
getProviderRuntimeBackendSummary,
getVisibleProviderRuntimeBackendOptions,
ProviderRuntimeBackendSelector,
@ -845,6 +846,11 @@ export const ProviderRuntimeSettingsDialog = ({
onRequestLogin,
}: Props): React.JSX.Element => {
const { t } = useAppTranslation('settings');
const { t: commonT } = useAppTranslation('common');
const runtimeBackendSummaryText = useMemo(
() => buildProviderRuntimeBackendSummaryText(commonT),
[commonT]
);
const [selectedProviderId, setSelectedProviderId] = useState<CliProviderId>(initialProviderId);
const [activeApiKeyFormProviderId, setActiveApiKeyFormProviderId] =
useState<ApiKeyProviderId | null>(null);
@ -1107,7 +1113,7 @@ export const ProviderRuntimeSettingsDialog = ({
? providerStatusLoading[selectedProvider.providerId] === true
: false;
const runtimeSummary = selectedProvider
? getProviderRuntimeBackendSummary(selectedProvider)
? getProviderRuntimeBackendSummary(selectedProvider, runtimeBackendSummaryText)
: null;
const codexConnection =
selectedProvider?.providerId === 'codex' ? (selectedProvider.connection?.codex ?? null) : null;

View file

@ -30,7 +30,10 @@ import {
shouldShowProviderStatusSkeleton,
} from '@renderer/components/runtime/providerConnectionUi';
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import {
buildProviderRuntimeBackendSummaryText,
getProviderRuntimeBackendSummary,
} from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import {
getProviderTerminalCommand,
@ -117,6 +120,11 @@ function getProviderLabel(providerId: CliProviderId): string {
export const CliStatusSection = (): React.JSX.Element | null => {
const { t } = useAppTranslation('settings');
const { t: commonT } = useAppTranslation('common');
const runtimeBackendSummaryText = useMemo(
() => buildProviderRuntimeBackendSummaryText(commonT),
[commonT]
);
const isElectron = useMemo(() => isElectronMode(), []);
const appConfig = useStore((s) => s.appConfig);
const selectedProjectId = useStore((s) => s.selectedProjectId);
@ -481,7 +489,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
isCodexSnapshotPending(provider, codexSnapshotPending);
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
? getProviderCurrentRuntimeSummary(provider, t)
: getProviderRuntimeBackendSummary(provider);
: getProviderRuntimeBackendSummary(provider, runtimeBackendSummaryText);
const sourceProvider =
loadingCliProviderMap.get(provider.providerId) ?? null;
const maskNegativeBootstrapState = shouldMaskCodexNegativeBootstrapState(

View file

@ -141,9 +141,14 @@ const TeamLogsSourceSelector = ({
getMemberLabel={(member) =>
isLeadMember(member)
? t('claudeLogs.sourceSelect.leadLabel')
: formatMemberLogSourceLabel(member)
: formatMemberLogSourceLabel(member, t('claudeLogs.sourceSelect.removedLabel'))
}
getMemberDescription={(member) =>
formatMemberLogSourceDescription(member, {
lead: t('claudeLogs.sourceSelect.leadDescription'),
removed: t('claudeLogs.sourceSelect.removedDescription'),
})
}
getMemberDescription={formatMemberLogSourceDescription}
/>
</div>
);

View file

@ -1352,14 +1352,16 @@ const LeadLoadBridge = memo(function LeadLoadBridge({
{t('detail.context.title')}
</p>
<p className="text-[10px] text-[var(--color-text-muted)]">
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
{leadSessionLoading
? t('detail.context.loading')
: t('detail.context.noSessionLoaded')}
</p>
</div>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setContextPanelVisible(false)}
aria-label={`Close ${teamName} context panel`}
aria-label={t('detail.context.closePanel', { team: teamName })}
>
×
</button>
@ -1367,8 +1369,8 @@ const LeadLoadBridge = memo(function LeadLoadBridge({
<div className="flex flex-1 items-center justify-center p-4">
<p className="text-xs text-[var(--color-text-muted)]">
{leadSessionLoading
? 'Loading context…'
: 'Open the team lead session to view context.'}
? t('detail.context.loadingContext')
: t('detail.context.openLeadSession')}
</p>
</div>
</div>
@ -1401,7 +1403,7 @@ const LeadLoadBridge = memo(function LeadLoadBridge({
leadSessionLoaded
? `Session: ${leadSessionId}`
: leadSessionLoading
? 'Loading context…'
? t('detail.context.loadingContext')
: leadSessionId
}
>

View file

@ -95,7 +95,11 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
type="button"
className="flex min-w-0 items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setCollapsed((v) => !v)}
aria-label={collapsed ? 'Expand in progress' : 'Collapse in progress'}
aria-label={
collapsed
? t('activity.activeTasks.expandInProgress')
: t('activity.activeTasks.collapseInProgress')
}
>
<ChevronRight
size={10}
@ -118,7 +122,10 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
);
const dotPing = kind === 'reviewing' ? 'bg-amber-400' : 'bg-emerald-400';
const dotSolid = kind === 'reviewing' ? 'bg-amber-500' : 'bg-emerald-500';
const activityLabel = kind === 'reviewing' ? 'reviewing' : 'working on';
const activityLabel =
kind === 'reviewing'
? t('activity.activeTasks.reviewing')
: t('activity.activeTasks.workingOn');
return (
<article

View file

@ -1380,7 +1380,9 @@ export const ActivityItem = memo(
) : isApiError ? (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
<AlertTriangle size={10} />
{message.messageKind === 'agent_error' ? 'Agent Error' : 'API Error'}
{message.messageKind === 'agent_error'
? t('activity.badges.agentError')
: t('activity.badges.apiError')}
</span>
) : null;

View file

@ -128,7 +128,11 @@ export const CodexReconnectPrompt = ({
}}
>
<LogIn className="size-3" />
{reconnectBusy ? 'Generating...' : authUrl ? 'Open login' : 'Generate link'}
{reconnectBusy
? t('codexReconnect.generating')
: authUrl
? t('codexReconnect.openLogin')
: t('codexReconnect.generateLink')}
</button>
</div>
</div>

View file

@ -18,8 +18,9 @@ interface OptionalSettingsSectionProps {
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]?8/i, 'Opus 4.8'],
[/claude[-\s]?opus[-\s]?4[-\s]?7/i, 'Opus 4.7'],
[/claude[-\s]?opus[-\s]?4[-\s]?6/i, 'Opus 4.6'],
[/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'],

View file

@ -1,9 +1,24 @@
import { OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE } from '@shared/utils/openCodeWindowsAccessDenied';
import {
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { describe, expect, it } from 'vitest';
import { getProvisioningFailureHint } from './ProvisioningProviderStatusList';
describe('getProvisioningFailureHint', () => {
it('returns the administrator hint for the exact OpenCode node_modules symlink permission failure', () => {
expect(
getProvisioningFailureHint(null, [
{
providerId: 'opencode',
status: 'failed',
details: [OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE],
},
])
).toBe('Run Agent Teams AI as Administrator, then retry launch.');
});
it('returns the OpenCode Windows permissions hint for OpenCode access-denied details', () => {
expect(
getProvisioningFailureHint(null, [

View file

@ -5,7 +5,9 @@ import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdent
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import {
isOpenCodeWindowsAccessDeniedDiagnostic,
isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic,
OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE,
OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE,
} from '@shared/utils/openCodeWindowsAccessDenied';
import { AlertTriangle, Check, CheckCircle2, Copy, Loader2, SlidersHorizontal } from 'lucide-react';
@ -1042,14 +1044,31 @@ export function getProvisioningFailureHint(
const hasOpenCodeAccessDeniedDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeWindowsAccessDeniedDiagnostic)
);
const hasOpenCodeNodeModulesSymlinkPermissionDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic)
);
const hasOpenCodeBridgeNoOutputDetail = failedOpenCodeChecks.some((check) =>
check.details.some(isOpenCodeBridgeNoOutputDiagnostic)
);
const normalizedMessage = message?.trim() ?? '';
const hasOpenCodeNodeModulesSymlinkPermissionMessage =
failedOpenCodeChecks.length > 0 &&
(normalizedMessage === OPENCODE_WINDOWS_NODE_MODULES_SYMLINK_PERMISSION_MESSAGE ||
(!hasFailedNonOpenCodeCheck &&
isOpenCodeWindowsNodeModulesSymlinkPermissionDiagnostic(normalizedMessage)));
const hasOpenCodeAccessDeniedMessage =
failedOpenCodeChecks.length > 0 &&
(normalizedMessage === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE ||
(!hasFailedNonOpenCodeCheck && isOpenCodeWindowsAccessDeniedDiagnostic(normalizedMessage)));
if (
hasOpenCodeNodeModulesSymlinkPermissionDetail ||
hasOpenCodeNodeModulesSymlinkPermissionMessage
) {
return (
t?.('provisioning.providerStatus.failureHints.openCodeNodeModulesSymlinkPermission') ??
'Run Agent Teams AI as Administrator, then retry launch.'
);
}
if (hasOpenCodeAccessDeniedDetail || hasOpenCodeAccessDeniedMessage) {
return (
t?.('provisioning.providerStatus.failureHints.openCodeAccessDenied') ??

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
@ -138,6 +138,73 @@ interface TaskDetailDialogProps {
headerExtra?: React.ReactNode;
}
function useTaskImplementationDurationClock(task: TeamTaskWithKanban): {
duration: ReturnType<typeof calculateTaskImplementationDuration>;
nowMs: number;
} {
const [nowMs, setNowMs] = useState(() => Date.now());
const duration = useMemo(() => calculateTaskImplementationDuration(task, nowMs), [task, nowMs]);
useEffect(() => {
if (!duration.hasRunningInterval) return;
setNowMs(Date.now());
const intervalId = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => window.clearInterval(intervalId);
}, [duration.hasRunningInterval, task.id]);
return { duration, nowMs };
}
const TaskImplementationDurationBadge = memo(function TaskImplementationDurationBadge({
task,
}: {
task: TeamTaskWithKanban;
}): React.JSX.Element | null {
const { t } = useAppTranslation('team');
const { duration } = useTaskImplementationDurationClock(task);
if (!shouldShowTaskImplementationDuration(duration)) {
return null;
}
return (
<span
className="inline-flex items-center gap-1 rounded-md bg-[var(--color-bg-secondary)] px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title={t('taskDetail.workflow.implementationTimeTitle')}
>
<Clock size={10} />
<span>
{t('taskDetail.workflow.inProgressTime', {
duration: formatTaskImplementationDuration(duration.elapsedMs),
})}
</span>
</span>
);
});
const WorkflowTimelineWithDuration = memo(function WorkflowTimelineWithDuration({
task,
events,
memberColorMap,
}: {
task: TeamTaskWithKanban;
events: NonNullable<TeamTaskWithKanban['historyEvents']>;
memberColorMap: Map<string, string>;
}): React.JSX.Element {
const { nowMs } = useTaskImplementationDurationClock(task);
return (
<WorkflowTimeline
events={events}
memberColorMap={memberColorMap}
implementationDurationTask={task}
nowMs={nowMs}
/>
);
});
export const TaskDetailDialog = ({
open,
loading = false,
@ -633,29 +700,6 @@ export const TaskDetailDialog = ({
: undefined
: undefined;
const [taskDurationNowMs, setTaskDurationNowMs] = useState(() => Date.now());
const taskImplementationDuration = useMemo(
() => calculateTaskImplementationDuration(currentTask, taskDurationNowMs),
[currentTask, taskDurationNowMs]
);
const showTaskImplementationDuration = shouldShowTaskImplementationDuration(
taskImplementationDuration
);
const taskImplementationDurationLabel = formatTaskImplementationDuration(
taskImplementationDuration.elapsedMs
);
useEffect(() => {
if (!open || !taskImplementationDuration.hasRunningInterval) return;
setTaskDurationNowMs(Date.now());
const intervalId = window.setInterval(() => {
setTaskDurationNowMs(Date.now());
}, 1000);
return () => window.clearInterval(intervalId);
}, [open, taskImplementationDuration.hasRunningInterval, currentTask?.id]);
if (loading) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
@ -1493,28 +1537,13 @@ export const TaskDetailDialog = ({
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
headerExtra={
showTaskImplementationDuration ? (
<span
className="inline-flex items-center gap-1 rounded-md bg-[var(--color-bg-secondary)] px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title={t('taskDetail.workflow.implementationTimeTitle')}
>
<Clock size={10} />
<span>
{t('taskDetail.workflow.inProgressTime', {
duration: taskImplementationDurationLabel,
})}
</span>
</span>
) : undefined
}
headerExtra={<TaskImplementationDurationBadge task={currentTask} />}
defaultOpen={false}
>
<WorkflowTimeline
<WorkflowTimelineWithDuration
task={currentTask}
events={currentTask.historyEvents}
memberColorMap={colorMap}
implementationDurationTask={currentTask}
nowMs={taskDurationNowMs}
/>
</CollapsibleTeamSection>
) : null}

View file

@ -1,5 +1,6 @@
import { useState } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
@ -24,6 +25,7 @@ export const MemberLaunchDiagnosticsButton = ({
size = label ? 'sm' : 'icon',
attention = false,
}: MemberLaunchDiagnosticsButtonProps): React.JSX.Element => {
const { t } = useAppTranslation('team');
const [copied, setCopied] = useState(false);
const copyDiagnostics = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
@ -39,7 +41,7 @@ export const MemberLaunchDiagnosticsButton = ({
};
const icon = copied ? <Check size={13} /> : <ClipboardList size={13} />;
const tooltip = copied ? 'Diagnostics copied' : 'Copy diagnostics';
const tooltip = copied ? t('provisioning.diagnosticsCopied') : t('provisioning.copyDiagnostics');
return (
<Tooltip>

View file

@ -1,5 +1,6 @@
import { useState } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { RoleSelect } from '@renderer/components/team/RoleSelect';
import { Button } from '@renderer/components/ui/button';
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
@ -18,6 +19,7 @@ export const MemberRoleEditor = ({
onCancel,
saving,
}: MemberRoleEditorProps): React.JSX.Element => {
const { t } = useAppTranslation('team');
const isPreset = currentRole && (PRESET_ROLES as readonly string[]).includes(currentRole);
const [selectValue, setSelectValue] = useState<string>(
!currentRole ? NO_ROLE : isPreset ? currentRole : CUSTOM_ROLE
@ -44,11 +46,11 @@ export const MemberRoleEditor = ({
}
const trimmed = customInput.trim();
if (!trimmed) {
setError('Role cannot be empty');
setError(t('roleSelect.emptyCustomRole'));
return;
}
if (FORBIDDEN_ROLES.has(trimmed.toLowerCase())) {
setError('This role is reserved');
setError(t('roleSelect.reservedRole'));
return;
}
void onSave(trimmed);
@ -68,7 +70,7 @@ export const MemberRoleEditor = ({
inputClassName="h-7 w-28 text-xs"
customRoleError={error}
onCustomRoleValidate={(val) => {
if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) return 'This role is reserved';
if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) return t('roleSelect.reservedRole');
return null;
}}
/>

View file

@ -26,13 +26,19 @@ export function getMemberNameFromLogSourceKey(sourceKey: TeamLogSourceKey): stri
return sourceKey.slice('member:'.length);
}
export function formatMemberLogSourceLabel(member: ResolvedTeamMember): string {
return member.removedAt ? `${member.name} (removed)` : member.name;
export function formatMemberLogSourceLabel(member: ResolvedTeamMember, removedLabel = 'removed'): string {
return member.removedAt ? `${member.name} (${removedLabel})` : member.name;
}
export function formatMemberLogSourceDescription(member: ResolvedTeamMember): string | null {
if (isLeadMember(member)) return 'Team Lead';
if (member.removedAt) return 'Removed';
export function formatMemberLogSourceDescription(
member: ResolvedTeamMember,
labels?: {
lead?: string;
removed?: string;
}
): string | null {
if (isLeadMember(member)) return labels?.lead ?? 'Team Lead';
if (member.removedAt) return labels?.removed ?? 'Removed';
return formatAgentRole(member.role) ?? formatAgentRole(member.agentType) ?? null;
}

View file

@ -0,0 +1,180 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
state: {
leadActivityByTeam: {} as Record<string, 'active' | 'idle' | undefined>,
teamDataByName: {} as Record<string, { isAlive?: boolean } | undefined>,
provisioningActiveByTeam: {} as Record<string, boolean | undefined>,
fetchTeamAgentRuntime: vi.fn(async (_teamName: string): Promise<void> => undefined),
},
}));
vi.mock('@renderer/store', () => ({
useStore: <T,>(selector: (state: typeof hoisted.state) => T): T => selector(hoisted.state),
}));
vi.mock('@renderer/store/slices/teamSlice', () => ({
isTeamProvisioningActive: (state: typeof hoisted.state, teamName: string): boolean =>
state.provisioningActiveByTeam[teamName] === true,
selectTeamDataForName: (
state: typeof hoisted.state,
teamName: string
): { isAlive?: boolean } | undefined => state.teamDataByName[teamName],
}));
import {
__resetTeamAgentRuntimeWatcherForTests,
useTeamAgentRuntimeWatcher,
} from './useTeamAgentRuntimeWatcher';
interface HookProbeProps {
teamName: string;
enabled: boolean;
isTeamProvisioning?: boolean;
isTeamAlive?: boolean;
}
const HookProbe = (props: HookProbeProps): null => {
useTeamAgentRuntimeWatcher(props);
return null;
};
const mountedRoots: Root[] = [];
async function renderWatcher(props: HookProbeProps): Promise<Root> {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
mountedRoots.push(root);
await act(async () => {
root.render(React.createElement(HookProbe, props));
await Promise.resolve();
});
return root;
}
describe('useTeamAgentRuntimeWatcher', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.useFakeTimers();
vi.setSystemTime(0);
__resetTeamAgentRuntimeWatcherForTests();
hoisted.state.leadActivityByTeam = {};
hoisted.state.teamDataByName = {};
hoisted.state.provisioningActiveByTeam = {};
hoisted.state.fetchTeamAgentRuntime.mockReset();
hoisted.state.fetchTeamAgentRuntime.mockResolvedValue(undefined);
});
afterEach(() => {
for (const root of mountedRoots.splice(0)) {
act(() => {
root.unmount();
});
}
document.body.innerHTML = '';
__resetTeamAgentRuntimeWatcherForTests();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('backs off polling for an alive idle team', async () => {
hoisted.state.teamDataByName['team-a'] = { isAlive: true };
await renderWatcher({ teamName: 'team-a', enabled: true });
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledWith('team-a');
await act(async () => {
vi.advanceTimersByTime(5_000);
await Promise.resolve();
});
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(10_000);
await Promise.resolve();
});
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(2);
});
it('keeps active teams on the fast polling cadence', async () => {
hoisted.state.leadActivityByTeam['team-a'] = 'active';
await renderWatcher({ teamName: 'team-a', enabled: true });
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(5_000);
await Promise.resolve();
});
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(2);
});
it('does not overlap runtime refreshes for the same team', async () => {
hoisted.state.leadActivityByTeam['team-a'] = 'active';
let resolveFirstRefresh: () => void = () => undefined;
const firstRefresh = new Promise<void>((resolve) => {
resolveFirstRefresh = resolve;
});
hoisted.state.fetchTeamAgentRuntime
.mockImplementationOnce(() => firstRefresh)
.mockResolvedValue(undefined);
await renderWatcher({ teamName: 'team-a', enabled: true });
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(5_000);
await Promise.resolve();
});
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
await act(async () => {
resolveFirstRefresh();
await firstRefresh;
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(5_000);
await Promise.resolve();
});
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(2);
});
it('coalesces multiple mounted watchers for one team', async () => {
hoisted.state.leadActivityByTeam['team-a'] = 'active';
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
mountedRoots.push(root);
await act(async () => {
root.render(
React.createElement(
React.Fragment,
null,
React.createElement(HookProbe, { teamName: 'team-a', enabled: true }),
React.createElement(HookProbe, { teamName: 'team-a', enabled: true })
)
);
await Promise.resolve();
});
expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1);
});
});

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