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}} 失败启动健康检查.",
"configuredNotFound": "未找到配置的 {{runtime}} 。",
"foundButFailed": "发现 {{runtime}} 失败启动",
"healthCheckFailedDescription": "该应用程序发现了配置的{{runtime}},但其启动健康检查失败. 修理或重新安装,然后重试。",
"configuredHealthCheckFailed": "配置的 {{runtime}} 启动运行状况检查失败。",
"configuredNotFound": "未找到配置的 {{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": "出了点问题"
}

File diff suppressed because it is too large Load diff

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}})",
"questionsAsked": "提出的问题({{count}})",
"outOfScopeFindings": "超出范围的调查结果 ({{count}})",
"questionsAsked": "提出的问题 ({{count}})",
"repeated": "重复",
"skillsInvoked": "被举报技能({{count}})",
"taskDispatches": "任务调度({{count}})",
"tasksCreated": "创建任务( {{count}})",
"skillsInvoked": "调用的技能 ({{count}})",
"taskDispatches": "任务调度 ({{count}})",
"tasksCreated": "创建任务 ({{count}})",
"teamMode": "团队模式",
"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}})",
"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": "关键外卖"
"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}})",
"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": "要点"
},
"quality": {
"chars": "字符",
"corrections": "惩戒",
"corrections": "修正",
"failed": "失败",
"fileReadRedundancy": "文件读取冗余",
"firstMessage": "第一消息",
"firstRun": "第一运行",
"firstMessage": "第一消息",
"firstRun": "第一运行",
"frictionRate": "摩擦率",
"lastRun": "次运行",
"messagesBeforeWork": "工作前的信件",
"lastRun": "最后一次运行",
"messagesBeforeWork": "开始工作前消息",
"passed": "通过",
"promptQuality": "提示质量",
"readsPerUniqueFile": "读取/ 唯一文件",
"snapshot": "简介",
"promptQuality": "提示质量",
"readsPerUniqueFile": "读取/唯一文件",
"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": "{{count}} 错误",
"count_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": "{{count}} 权限拒绝次数",
"permissionDenialCount_one": "{{count}} 权限拒绝次数",
"count_few": "{{count}} 错误",
"count_many": "{{count}} 错误",
"count_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
);
}
function isRuntimeLeadEntryActiveForWorkSync(
entry:
| Pick<
TeamAgentRuntimeEntry,
'alive' | 'backendType' | 'livenessKind' | 'memberName' | 'pidSource'
>
| null
| undefined
): boolean {
if (!entry || !isWorkSyncLeadLikeMemberName(entry.memberName)) {
return false;
}
return !WORK_SYNC_INACTIVE_LIVENESS_KINDS.has(entry.livenessKind);
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