From 85959b695420d81d25733e8702e86b76c5f2464d Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 19 May 2026 16:19:38 +0300 Subject: [PATCH] feat(runtime): improve provider readiness diagnostics --- README.md | 14 +- landing/assets/styles/cyberpunk-hero.scss | 18 +- landing/components/common/AppLogo.vue | 7 +- landing/components/layout/AppFooter.vue | 31 +- landing/components/layout/AppHeader.vue | 38 +- .../components/sections/ComparisonSection.vue | 4 +- .../components/sections/DownloadSection.vue | 5 + landing/components/sections/HeroSection.vue | 3 +- .../components/sections/PricingSection.vue | 68 ++- landing/content/ar.json | 14 +- landing/content/de.json | 14 +- landing/content/en.json | 14 +- landing/content/es.json | 14 +- landing/content/fr.json | 14 +- landing/content/hi.json | 14 +- landing/content/ja.json | 14 +- landing/content/pt.json | 14 +- landing/content/ru.json | 14 +- landing/content/zh.json | 14 +- landing/layouts/default.vue | 14 + landing/locales/ar.json | 9 +- landing/locales/de.json | 9 +- landing/locales/en.json | 9 +- landing/locales/es.json | 9 +- landing/locales/fr.json | 9 +- landing/locales/hi.json | 9 +- landing/locales/ja.json | 9 +- landing/locales/pt.json | 9 +- landing/locales/ru.json | 9 +- landing/locales/zh.json | 9 +- landing/product-docs/guide/installation.md | 8 +- landing/product-docs/guide/quickstart.md | 10 +- landing/product-docs/guide/runtime-setup.md | 9 +- .../reference/providers-runtimes.md | 2 +- landing/product-docs/ru/guide/installation.md | 6 +- landing/product-docs/ru/guide/quickstart.md | 12 +- .../product-docs/ru/guide/runtime-setup.md | 8 +- .../ru/reference/providers-runtimes.md | 2 +- .../codexAppServer/CodexBinaryResolver.ts | 72 ++- .../CodexBinaryResolver.real.test.ts | 134 ++++++ .../__tests__/CodexBinaryResolver.test.ts | 131 ++++++ .../team/TeamMemberRuntimeAdvisoryService.ts | 8 - .../OpenCodeRuntimeDeliveryAdvisoryPolicy.ts | 19 +- .../readiness/OpenCodeTeamLaunchReadiness.ts | 54 ++- .../team/stallMonitor/TeamTaskStallPolicy.ts | 12 +- .../team/stallMonitor/featureGates.ts | 6 +- .../team/dialogs/TeamModelSelector.tsx | 97 +++- ...nCodeRuntimeDeliveryAdvisoryPolicy.test.ts | 52 +++ .../team/OpenCodeTeamLaunchReadiness.test.ts | 71 ++- .../TeamMemberRuntimeAdvisoryService.test.ts | 426 +++++++++++++++++- .../stallMonitor/TeamTaskStallPolicy.test.ts | 226 +++++++++- .../team/stallMonitor/featureGates.test.ts | 10 +- .../TeamModelSelectorDisabledState.test.ts | 119 +++++ 53 files changed, 1654 insertions(+), 272 deletions(-) create mode 100644 src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.real.test.ts diff --git a/README.md b/README.md index 31278462..6346827c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

- Free desktop app for AI agent teams. Auto-detects Claude/Codex/OpenCode (200+ models). Use the provider access you already have - subscriptions or API keys. Not just coding agents. + Free desktop app for AI agent teams. Start with a free model with no auth - no signup, API key, or card - or connect Claude/Codex/OpenCode provider access for more models. Not just coding agents.

image @@ -109,7 +109,7 @@ If you want the FRESHEST version, clone the repo and run it from the `dev` branc An orchestration layer for AI agent teams across Claude, Codex, and OpenCode. -- **Claude + Codex + OpenCode orchestration** — auto-detect available Claude/Codex/OpenCode runtimes and use the provider access you already have - subscriptions or API keys +- **Claude + Codex + OpenCode orchestration** — start with a free model with no auth immediately, or auto-detect available Claude/Codex/OpenCode runtimes and use the provider access you already have - subscriptions or API keys - **Assemble your team** — create agent teams with different roles that work autonomously in parallel - **Agents talk to each other** — they communicate, create and manage their own tasks, review, leave comments - **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams @@ -137,7 +137,7 @@ An orchestration layer for AI agent teams across Claude, Codex, and OpenCode. - **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place -- **Zero-setup onboarding** — built-in runtime detection and provider authentication +- **Zero-setup onboarding** — start with the free model with no auth, then connect paid/account providers only when you need them - **Built-in code editor** — edit project files with Git support without leaving the app @@ -197,7 +197,7 @@ For feature architecture and implementation guidance: | **Teammate launch status** | ✅ Know who started, who is stuck, and who replied | ⚠️ Session health, less clear message status | ⚠️ Run status, not live teammate status | ❌ | ⚠️ CLI mailbox, no visual status | | **Org chart / governance** | ⚠️ Roles + approvals, no org chart | ⚠️ Roles + escalation | ✅ Org chart + board governance | ⚠️ Team admin only | ❌ | | **Budget controls** | ⚠️ Cost/token visibility, no hard caps | ⚠️ Cost tiers + digest, no hard caps | ✅ Per-agent budgets + hard stops | ⚠️ Usage + BG spend limits | ⚠️ `/usage` + workspace limits | -| **Price** | **Free OSS UI**, provider access needed | Free OSS, runtime plans needed | Free OSS, self-hosted + infra | Free + paid usage | Claude plan or API usage | +| **Price** | **Free OSS UI + free model with no auth**, paid providers optional | Free OSS, runtime plans needed | Free OSS, self-hosted + infra | Free + paid usage | Claude plan or API usage | Fact sources checked on May 18, 2026: [detailed research notes](docs/research/gastown-paperclip-comparison-2026-05-16.md), [Gastown README](https://github.com/gastownhall/gastown), [Gastown provider guide](https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md), [Gastown scheduler](https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md), [Gastown dashboard source](https://github.com/gastownhall/gastown/blob/main/internal/web/templates/convoy.html), [Gastown release](https://github.com/gastownhall/gastown/releases/tag/v1.1.0), [Paperclip README](https://github.com/paperclipai/paperclip), [Paperclip adapters](https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md), [Paperclip heartbeat protocol](https://github.com/paperclipai/paperclip/blob/master/docs/guides/agent-developer/heartbeat-protocol.md), [Paperclip org chart](https://paperclip.inc/docs/guides/board-operator/org-structure/), [Paperclip OrgChart source](https://github.com/paperclipai/paperclip/blob/master/ui/src/pages/OrgChart.tsx), [Paperclip budgets](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md), [Paperclip runtime services](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md), [Paperclip Kanban source](https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx), [Paperclip work products](https://github.com/paperclipai/paperclip/blob/master/packages/shared/src/validators/work-product.ts), [Paperclip release](https://github.com/paperclipai/paperclip/releases/tag/v2026.517.0), [Cursor Background Agents](https://docs.cursor.com/en/background-agents), [Cursor Diffs & Review](https://docs.cursor.com/en/agent/review), [Cursor Bugbot](https://docs.cursor.com/en/bugbot), [Cursor pricing](https://docs.cursor.com/en/account/usage), [Claude Code agent teams](https://code.claude.com/docs/en/agent-teams), [Claude Code subagents](https://code.claude.com/docs/en/sub-agents), [Claude Code workflows](https://code.claude.com/docs/en/common-workflows), [Claude Code costs](https://code.claude.com/docs/en/costs), [Claude pricing](https://claude.com/pricing). @@ -206,7 +206,7 @@ Fact sources checked on May 18, 2026: [detailed research notes](docs/research/ga ## Quick start 1. **Download** the app for your platform (see [Installation](#installation)) -2. **Launch the desktop app** - On first run, the setup wizard will detect the runtime and guide provider authentication +2. **Launch the desktop app** - start with the free model with no auth, or let the setup wizard detect runtimes and guide provider authentication 3. **Create a team** — Pick a project, define roles, write a provisioning prompt 4. **Watch** — Agents spawn, create tasks, and work. You see it all on the kanban board @@ -220,7 +220,7 @@ Use the desktop app as the primary product. The browser/web path is not needed f
Do I need to install a runtime before using this app?
-No. The app guides runtime detection/setup and provider authentication from the UI - just launch and follow the setup wizard. +No. You can start with the free model with no auth right away. If you want Claude, Codex, OpenCode/OpenRouter, or other provider-backed models, the app guides runtime detection/setup and provider authentication from the UI.
@@ -238,7 +238,7 @@ Yes. Agents send direct messages, create shared tasks, and leave comments - all
Is it free?
-Yes, free and open source. The app has no paid tier of its own. To run agents, you only need access to a supported provider/runtime, such as Anthropic or Codex. +Yes. The app is free and open source, and you can start with a free model with no auth - no registration, API keys, or credit card. If you want more models, connect the provider access you already have, such as Claude, Codex, OpenCode/OpenRouter, or other supported runtimes.
diff --git a/landing/assets/styles/cyberpunk-hero.scss b/landing/assets/styles/cyberpunk-hero.scss index cc3ebccd..d4feb739 100644 --- a/landing/assets/styles/cyberpunk-hero.scss +++ b/landing/assets/styles/cyberpunk-hero.scss @@ -24,17 +24,18 @@ radial-gradient(circle at 86% 70%, rgba(255, 43, 255, 0.14), transparent 34%), linear-gradient(180deg, var(--cyber-bg-0), var(--cyber-bg-1) 58%, var(--cyber-bg-0)); --cyber-monterey-bg: - radial-gradient(circle at 76% 22%, rgba(138, 47, 255, 0.76), transparent 30%), - radial-gradient(circle at 18% 76%, rgba(37, 8, 128, 0.72), transparent 36%), - linear-gradient(180deg, #180061 0%, #3200a2 46%, #130042 100%); + radial-gradient(circle at 76% 22%, rgba(47, 125, 255, 0.22), transparent 30%), + radial-gradient(circle at 18% 76%, rgba(37, 8, 128, 0.22), transparent 36%), + radial-gradient(circle at 88% 68%, rgba(255, 43, 255, 0.1), transparent 34%), + linear-gradient(180deg, #030614 0%, #07122a 46%, #02050d 100%); --cyber-monterey-before-bg: radial-gradient(circle at 18% 34%, rgba(2, 5, 13, 0.62), rgba(2, 5, 13, 0.14) 34%, transparent 62%), linear-gradient(90deg, rgba(2, 5, 13, 0.48) 0%, rgba(2, 5, 13, 0.17) 42%, rgba(2, 5, 13, 0.04) 64%, rgba(2, 5, 13, 0.3) 100%); --cyber-monterey-after-bg: linear-gradient(180deg, rgba(2, 5, 13, 0.92) 0%, rgba(2, 5, 13, 0.62) 15%, rgba(2, 5, 13, 0.08) 44%, rgba(2, 5, 13, 0.68) 100%), radial-gradient(circle at 64% 42%, transparent 0 26%, rgba(2, 5, 13, 0.24) 70%, rgba(2, 5, 13, 0.54) 100%); - --cyber-monterey-canvas-opacity: 1; - --cyber-monterey-canvas-filter: blur(4px) saturate(1.22) brightness(1.08) contrast(1.08); + --cyber-monterey-canvas-opacity: 0.42; + --cyber-monterey-canvas-filter: blur(4px) saturate(0.78) brightness(0.62) contrast(1.08); --cyber-monterey-canvas-blend: normal; --cyber-background-bg: radial-gradient(circle at 72% 28%, rgba(0, 234, 255, 0.1), transparent 30%), @@ -454,7 +455,7 @@ flex-direction: row; flex-wrap: nowrap; gap: 0.18em; - font-size: clamp(2.55rem, 5.1vw, 5.7rem); + font-size: clamp(2.45rem, 4.8vw, 5.35rem); line-height: 1; font-weight: 900; letter-spacing: 0; @@ -1468,7 +1469,7 @@ } .cyber-hero__title { - font-size: clamp(3rem, 4.6vw, 4.8rem); + font-size: clamp(2.8rem, 4.2vw, 4.45rem); } .cyber-scene { @@ -1609,7 +1610,8 @@ } .cyber-hero__title { - font-size: clamp(2.55rem, 13vw, 4rem); + gap: 0.12em; + font-size: clamp(2rem, 9.4vw, 3.1rem); } .cyber-hero__slogan { diff --git a/landing/components/common/AppLogo.vue b/landing/components/common/AppLogo.vue index cd59e367..92c8094f 100644 --- a/landing/components/common/AppLogo.vue +++ b/landing/components/common/AppLogo.vue @@ -6,12 +6,12 @@ const { baseURL } = useRuntimeConfig().app; @@ -46,12 +46,13 @@ const { baseURL } = useRuntimeConfig().app; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; + text-shadow: 0 0 16px rgba(0, 240, 255, 0.22); } diff --git a/landing/locales/ar.json b/landing/locales/ar.json index 23905f5f..61e3f987 100644 --- a/landing/locales/ar.json +++ b/landing/locales/ar.json @@ -43,11 +43,12 @@ "sectionSubtitle": "أدوات قوية تجعل التعاون متعدد الوكلاء يعمل فعلاً." }, "pricing": { - "sectionTitle": "مجاني 100%. بدون شروط.", - "sectionSubtitle": "مفتوح المصدر، بدون مفاتيح API، بدون إعدادات. فقط ثبّت وابدأ.", + "sectionTitle": "التثبيت مجاني. نموذج مجاني مضمّن.", + "sectionSubtitle": "ابدأ فوراً بنموذج مجاني بدون مصادقة - بدون حساب أو مفتاح API أو بطاقة. وصّل Claude أو Codex أو OpenCode/OpenRouter أو مزودين آخرين فقط عندما تحتاج نماذج إضافية.", "getStarted": "حمّل الآن", "popular": "مجاني", - "note": "مفتوح المصدر. بدون مفاتيح API. بدون إعدادات. يعمل محلياً بالكامل." + "freeModelCallout": "نموذج مجاني بدون مصادقة مضمّن", + "note": "لا يملك Agent Teams أي خطة مدفوعة. النموذج المجاني بدون مصادقة يتيح التجربة فوراً؛ استخدام المزودين المدفوعين اختياري ويتبع المزود الذي تختاره." }, "testimonials": { "sectionTitle": "ماذا يقول المطورون", @@ -112,7 +113,7 @@ }, "meta": { "homeTitle": "Agent Teams - تنسيق وكلاء الذكاء الاصطناعي للمطورين", - "homeDescription": "تطبيق سطح مكتب مجاني ومفتوح المصدر لتشكيل فرق وكلاء الذكاء الاصطناعي. لوحة كانبان، مراجعة الكود، تواصل بين الفرق. يعمل محلياً بالكامل.", + "homeDescription": "تطبيق سطح مكتب مجاني ومفتوح المصدر لفرق وكلاء الذكاء الاصطناعي. ابدأ بنموذج مجاني بدون مصادقة، ثم وصّل Claude أو Codex أو OpenCode عند الحاجة.", "downloadTitle": "تنزيل Agent Teams لنظام macOS وWindows وLinux", "downloadDescription": "نزّل Agent Teams لنظام macOS وWindows وLinux. تطبيق سطح مكتب مجاني ومفتوح المصدر لفرق وكلاء Claude وCodex وOpenCode." }, diff --git a/landing/locales/de.json b/landing/locales/de.json index 9957e737..f407c51b 100644 --- a/landing/locales/de.json +++ b/landing/locales/de.json @@ -43,11 +43,12 @@ "sectionSubtitle": "Leistungsstarke Tools für effektive Multi-Agenten-Zusammenarbeit." }, "pricing": { - "sectionTitle": "100% Kostenlos. Ohne Haken.", - "sectionSubtitle": "Open Source, keine API-Schlüssel, keine Konfiguration. Einfach installieren und loslegen.", + "sectionTitle": "Kostenlos installieren. Kostenloses Modell inklusive.", + "sectionSubtitle": "Starten Sie sofort mit einem kostenlosen Modell ohne Authentifizierung - ohne Konto, API-Schlüssel oder Kreditkarte. Verbinden Sie Claude, Codex, OpenCode/OpenRouter oder andere Provider nur, wenn Sie mehr Modelle möchten.", "getStarted": "Herunterladen", "popular": "Kostenlos", - "note": "Open Source. Keine API-Schlüssel. Keine Konfiguration. Läuft vollständig lokal." + "freeModelCallout": "Kostenloses Modell ohne Authentifizierung inklusive", + "note": "Agent Teams hat keinen eigenen Bezahlplan. Mit dem kostenlosen Modell ohne Authentifizierung können Sie sofort testen; kostenpflichtige Provider-Nutzung ist optional und hängt vom gewählten Provider ab." }, "testimonials": { "sectionTitle": "Was Entwickler sagen", @@ -112,7 +113,7 @@ }, "meta": { "homeTitle": "Agent Teams - KI-Agenten-Orchestrierung für Entwickler", - "homeDescription": "Kostenlose Open-Source-Desktop-App für KI-Agenten-Teams. Kanban-Board, Code-Review, teamübergreifende Kommunikation. Läuft vollständig lokal.", + "homeDescription": "Kostenlose Open-Source-Desktop-App für KI-Agenten-Teams. Starten Sie mit einem kostenlosen Modell ohne Authentifizierung und verbinden Sie Claude, Codex oder OpenCode bei Bedarf.", "downloadTitle": "Agent Teams für macOS, Windows und Linux herunterladen", "downloadDescription": "Laden Sie Agent Teams für macOS, Windows und Linux herunter. Kostenlose Open-Source-Desktop-App für Claude-, Codex- und OpenCode-Agententeams." }, diff --git a/landing/locales/en.json b/landing/locales/en.json index 6d7ecf2a..fc0f0476 100644 --- a/landing/locales/en.json +++ b/landing/locales/en.json @@ -43,11 +43,12 @@ "sectionSubtitle": "Powerful tools that make multi-agent collaboration actually work." }, "pricing": { - "sectionTitle": "100% Free. No strings attached.", - "sectionSubtitle": "Open source, no API keys, no configuration. Just install and go.", + "sectionTitle": "Free to install. Free model included.", + "sectionSubtitle": "Start immediately with a free model with no auth - no account, API key, or credit card. Connect Claude, Codex, OpenCode/OpenRouter, or other provider access only when you want more models.", "getStarted": "Download Now", "popular": "Free", - "note": "Open source. No API keys. No configuration. Runs entirely locally." + "freeModelCallout": "Free model with no auth included", + "note": "Agent Teams has no paid tier. The free model with no auth lets you try it right away; paid provider usage is optional and controlled by the provider you choose." }, "testimonials": { "sectionTitle": "What developers say", @@ -112,7 +113,7 @@ }, "meta": { "homeTitle": "Agent Teams - AI Agent Orchestration for Developers", - "homeDescription": "Free, open-source desktop app for assembling AI agent teams. Kanban board, code review, cross-team communication. Runs entirely locally.", + "homeDescription": "Free, open-source desktop app for AI agent teams. Start with a free model with no auth, then connect Claude, Codex, or OpenCode when you need more models.", "downloadTitle": "Download Agent Teams for macOS, Windows, and Linux", "downloadDescription": "Download Agent Teams for macOS, Windows, and Linux. Free open-source desktop app for Claude, Codex, and OpenCode agent teams." }, diff --git a/landing/locales/es.json b/landing/locales/es.json index 176ca3a0..579f122f 100644 --- a/landing/locales/es.json +++ b/landing/locales/es.json @@ -43,11 +43,12 @@ "sectionSubtitle": "Herramientas potentes que hacen que la colaboración multi-agente realmente funcione." }, "pricing": { - "sectionTitle": "100% Gratis. Sin letra pequeña.", - "sectionSubtitle": "Código abierto, sin claves API, sin configuración. Instala y empieza.", + "sectionTitle": "Gratis para instalar. Modelo gratis incluido.", + "sectionSubtitle": "Empieza al instante con un modelo gratuito sin autenticación - sin cuenta, clave API ni tarjeta. Conecta Claude, Codex, OpenCode/OpenRouter u otros proveedores solo si quieres más modelos.", "getStarted": "Descargar ahora", "popular": "Gratis", - "note": "Código abierto. Sin claves API. Sin configuración. Funciona completamente en local." + "freeModelCallout": "Modelo gratuito sin autenticación incluido", + "note": "Agent Teams no tiene plan de pago propio. El modelo gratuito sin autenticación te permite probarlo de inmediato; el uso de proveedores de pago es opcional y depende del proveedor que elijas." }, "testimonials": { "sectionTitle": "Lo que dicen los desarrolladores", @@ -112,7 +113,7 @@ }, "meta": { "homeTitle": "Agent Teams - Orquestación de agentes IA para desarrolladores", - "homeDescription": "App de escritorio gratuita y de código abierto para montar equipos de agentes IA. Tablero kanban, revisión de código, comunicación entre equipos. Funciona completamente en local.", + "homeDescription": "App de escritorio gratuita y open source para equipos de agentes IA. Empieza con un modelo gratuito sin autenticación, y conecta Claude, Codex u OpenCode cuando necesites más modelos.", "downloadTitle": "Descargar Agent Teams para macOS, Windows y Linux", "downloadDescription": "Descarga Agent Teams para macOS, Windows y Linux. App de escritorio gratis y open source para equipos de agentes Claude, Codex y OpenCode." }, diff --git a/landing/locales/fr.json b/landing/locales/fr.json index 57738f7a..675c6833 100644 --- a/landing/locales/fr.json +++ b/landing/locales/fr.json @@ -43,11 +43,12 @@ "sectionSubtitle": "Des outils puissants pour une collaboration multi-agents efficace." }, "pricing": { - "sectionTitle": "100% Gratuit. Sans conditions.", - "sectionSubtitle": "Open source, sans clé API, sans configuration. Installez et c'est parti.", + "sectionTitle": "Installation gratuite. Modèle gratuit inclus.", + "sectionSubtitle": "Commencez immédiatement avec un modèle gratuit sans authentification - sans compte, clé API ni carte bancaire. Connectez Claude, Codex, OpenCode/OpenRouter ou un autre provider seulement si vous voulez plus de modèles.", "getStarted": "Télécharger", "popular": "Gratuit", - "note": "Open source. Sans clé API. Sans configuration. Fonctionne entièrement en local." + "freeModelCallout": "Modèle gratuit sans authentification inclus", + "note": "Agent Teams n'a pas d'offre payante. Le modèle gratuit sans authentification permet d'essayer tout de suite; l'usage de providers payants est optionnel et dépend du provider choisi." }, "testimonials": { "sectionTitle": "Ce que disent les développeurs", @@ -112,7 +113,7 @@ }, "meta": { "homeTitle": "Agent Teams - Orchestration d'agents IA pour développeurs", - "homeDescription": "Application desktop gratuite et open source pour gérer des équipes d'agents IA. Tableau kanban, revue de code, communication inter-équipes. Fonctionne entièrement en local.", + "homeDescription": "Application desktop gratuite et open source pour équipes d'agents IA. Commencez avec un modèle gratuit sans authentification, puis connectez Claude, Codex ou OpenCode si besoin.", "downloadTitle": "Télécharger Agent Teams pour macOS, Windows et Linux", "downloadDescription": "Téléchargez Agent Teams pour macOS, Windows et Linux. Application desktop gratuite et open source pour équipes d'agents Claude, Codex et OpenCode." }, diff --git a/landing/locales/hi.json b/landing/locales/hi.json index fb38cc89..db802dcf 100644 --- a/landing/locales/hi.json +++ b/landing/locales/hi.json @@ -43,11 +43,12 @@ "sectionSubtitle": "शक्तिशाली उपकरण जो मल्टी-एजेंट सहयोग को वास्तव में काम करते हैं।" }, "pricing": { - "sectionTitle": "100% मुफ़्त। कोई शर्त नहीं।", - "sectionSubtitle": "ओपन सोर्स, कोई API कुंजी नहीं, कोई कॉन्फ़िगरेशन नहीं। बस इंस्टॉल करें और शुरू करें।", + "sectionTitle": "इंस्टॉल मुफ़्त। मुफ़्त मॉडल शामिल।", + "sectionSubtitle": "मुफ़्त no-auth model से तुरंत शुरू करें - कोई account, API key या credit card नहीं। Claude, Codex, OpenCode/OpenRouter या अन्य providers तभी जोड़ें जब आपको और models चाहिए।", "getStarted": "अभी डाउनलोड करें", "popular": "मुफ़्त", - "note": "ओपन सोर्स। कोई API कुंजी नहीं। कोई कॉन्फ़िगरेशन नहीं। पूरी तरह लोकल चलता है।" + "freeModelCallout": "मुफ़्त no-auth model शामिल", + "note": "Agent Teams का अपना कोई paid plan नहीं है। मुफ़्त no-auth model से आप तुरंत try कर सकते हैं; paid provider usage optional है और चुने गए provider पर निर्भर है।" }, "testimonials": { "sectionTitle": "डेवलपर्स क्या कहते हैं", @@ -112,7 +113,7 @@ }, "meta": { "homeTitle": "Agent Teams - डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन", - "homeDescription": "AI एजेंट टीमें बनाने के लिए मुफ़्त, ओपन-सोर्स डेस्कटॉप ऐप। कानबन बोर्ड, कोड रिव्यू, क्रॉस-टीम संचार। पूरी तरह लोकल चलता है।", + "homeDescription": "AI agent teams के लिए मुफ़्त open-source desktop app। मुफ़्त no-auth model से शुरू करें, फिर ज़रूरत पर Claude, Codex या OpenCode जोड़ें।", "downloadTitle": "macOS, Windows और Linux के लिए Agent Teams डाउनलोड करें", "downloadDescription": "macOS, Windows और Linux के लिए Agent Teams डाउनलोड करें। Claude, Codex और OpenCode एजेंट टीमों के लिए मुफ़्त ओपन-सोर्स डेस्कटॉप ऐप।" }, diff --git a/landing/locales/ja.json b/landing/locales/ja.json index 50d7414d..4f957f5f 100644 --- a/landing/locales/ja.json +++ b/landing/locales/ja.json @@ -43,11 +43,12 @@ "sectionSubtitle": "マルチエージェント連携を実現する強力なツール。" }, "pricing": { - "sectionTitle": "100%無料。制約なし。", - "sectionSubtitle": "オープンソース、APIキー不要、設定不要。インストールするだけ。", + "sectionTitle": "インストール無料。無料モデル付き。", + "sectionSubtitle": "認証なしの無料モデルですぐに開始できます - アカウント、APIキー、クレジットカードは不要。追加モデルが必要な時だけ Claude、Codex、OpenCode/OpenRouter などを接続できます。", "getStarted": "ダウンロード", "popular": "無料", - "note": "オープンソース。APIキー不要。設定不要。完全にローカルで動作。" + "freeModelCallout": "認証なしの無料モデルが含まれています", + "note": "Agent Teams 自体に有料プランはありません。認証なしの無料モデルですぐに試せます。有料 provider の利用は任意で、選択した provider の条件に従います。" }, "testimonials": { "sectionTitle": "開発者の声", @@ -112,7 +113,7 @@ }, "meta": { "homeTitle": "Agent Teams - 開発者向けAIエージェントオーケストレーション", - "homeDescription": "AIエージェントチームを管理する無料のオープンソースデスクトップアプリ。カンバンボード、コードレビュー、チーム間通信。完全にローカルで動作。", + "homeDescription": "AIエージェントチーム向けの無料オープンソースデスクトップアプリ。認証なしの無料モデルから始め、必要に応じて Claude、Codex、OpenCode を接続できます。", "downloadTitle": "macOS、Windows、Linux向けAgent Teamsをダウンロード", "downloadDescription": "macOS、Windows、Linux向けAgent Teamsをダウンロード。Claude、Codex、OpenCodeエージェントチーム用の無料オープンソースデスクトップアプリ。" }, diff --git a/landing/locales/pt.json b/landing/locales/pt.json index 0c83735e..f5b7960c 100644 --- a/landing/locales/pt.json +++ b/landing/locales/pt.json @@ -43,11 +43,12 @@ "sectionSubtitle": "Ferramentas poderosas que fazem a colaboração multi-agente realmente funcionar." }, "pricing": { - "sectionTitle": "100% Grátis. Sem pegadinhas.", - "sectionSubtitle": "Código aberto, sem chaves de API, sem configuração. Instale e comece.", + "sectionTitle": "Grátis para instalar. Modelo grátis incluído.", + "sectionSubtitle": "Comece na hora com um modelo gratuito sem autenticação - sem conta, chave de API ou cartão. Conecte Claude, Codex, OpenCode/OpenRouter ou outros provedores só quando quiser mais modelos.", "getStarted": "Baixar agora", "popular": "Grátis", - "note": "Código aberto. Sem chaves de API. Sem configuração. Roda totalmente local." + "freeModelCallout": "Modelo gratuito sem autenticação incluído", + "note": "Agent Teams não tem plano pago próprio. O modelo gratuito sem autenticação permite testar imediatamente; uso de provedores pagos é opcional e depende do provedor escolhido." }, "testimonials": { "sectionTitle": "O que os desenvolvedores dizem", @@ -112,7 +113,7 @@ }, "meta": { "homeTitle": "Agent Teams - Orquestração de agentes IA para desenvolvedores", - "homeDescription": "App desktop gratuito e open source para montar equipes de agentes IA. Quadro kanban, revisão de código, comunicação entre equipes. Roda totalmente local.", + "homeDescription": "App desktop gratuito e open source para equipes de agentes IA. Comece com um modelo gratuito sem autenticação, depois conecte Claude, Codex ou OpenCode quando precisar.", "downloadTitle": "Baixar Agent Teams para macOS, Windows e Linux", "downloadDescription": "Baixe o Agent Teams para macOS, Windows e Linux. App desktop gratuito e open source para equipes de agentes Claude, Codex e OpenCode." }, diff --git a/landing/locales/ru.json b/landing/locales/ru.json index 241c30a3..3d31af0c 100644 --- a/landing/locales/ru.json +++ b/landing/locales/ru.json @@ -43,11 +43,12 @@ "sectionSubtitle": "Мощные инструменты, которые делают мультиагентную совместную работу реальностью." }, "pricing": { - "sectionTitle": "100% Бесплатно. Без подвоха.", - "sectionSubtitle": "Открытый код, без API-ключей, без конфигурации. Просто установите и работайте.", + "sectionTitle": "Бесплатно установить. Бесплатная модель уже внутри.", + "sectionSubtitle": "Можно сразу начать с бесплатной модели без авторизации - без регистрации, API-ключей и карты. Claude, Codex, OpenCode/OpenRouter и другие провайдеры подключайте только если нужны дополнительные модели.", "getStarted": "Скачать", "popular": "Бесплатно", - "note": "Открытый код. Без API-ключей. Без конфигурации. Работает полностью локально." + "freeModelCallout": "Бесплатная модель без авторизации включена", + "note": "У Agent Teams нет платного тарифа. Бесплатная модель без авторизации даёт попробовать сразу; платные провайдеры опциональны и зависят от выбранного сервиса." }, "testimonials": { "sectionTitle": "Что говорят разработчики", @@ -112,7 +113,7 @@ }, "meta": { "homeTitle": "Agent Teams - Оркестрация ИИ-агентов для разработчиков", - "homeDescription": "Бесплатное десктопное приложение с открытым кодом для управления командами ИИ-агентов. Канбан-доска, код-ревью, межкомандная коммуникация. Работает полностью локально.", + "homeDescription": "Бесплатное open-source desktop-приложение для команд ИИ-агентов. Начните с бесплатной модели без авторизации, а Claude, Codex или OpenCode подключайте когда нужны дополнительные модели.", "downloadTitle": "Скачать Agent Teams для macOS, Windows и Linux", "downloadDescription": "Скачайте Agent Teams для macOS, Windows и Linux. Бесплатное open-source приложение для команд агентов Claude, Codex и OpenCode." }, diff --git a/landing/locales/zh.json b/landing/locales/zh.json index 63de8336..66cd073e 100644 --- a/landing/locales/zh.json +++ b/landing/locales/zh.json @@ -43,11 +43,12 @@ "sectionSubtitle": "强大的工具,让多智能体协作真正有效。" }, "pricing": { - "sectionTitle": "100% 免费,没有附加条件。", - "sectionSubtitle": "开源,无需 API 密钥,无需配置。安装即用。", + "sectionTitle": "免费安装。内置免费模型。", + "sectionSubtitle": "可立即使用无需认证的免费模型 - 无需账号、API 密钥或信用卡。只有在需要更多模型时,才连接 Claude、Codex、OpenCode/OpenRouter 或其他提供商。", "getStarted": "立即下载", "popular": "免费", - "note": "开源。无需 API 密钥。无需配置。完全本地运行。" + "freeModelCallout": "已包含无需认证的免费模型", + "note": "Agent Teams 没有自己的付费套餐。无需认证的免费模型可让你立即试用;付费提供商的使用是可选的,并由你选择的提供商控制。" }, "testimonials": { "sectionTitle": "开发者怎么说", @@ -112,7 +113,7 @@ }, "meta": { "homeTitle": "Agent Teams - 面向开发者的 AI 智能体编排", - "homeDescription": "免费开源桌面应用,用于组建 AI 智能体团队。看板、代码审查、跨团队通信。完全本地运行。", + "homeDescription": "面向 AI 智能体团队的免费开源桌面应用。先使用无需认证的免费模型,需要更多模型时再连接 Claude、Codex 或 OpenCode。", "downloadTitle": "下载适用于 macOS、Windows 和 Linux 的 Agent Teams", "downloadDescription": "下载适用于 macOS、Windows 和 Linux 的 Agent Teams。面向 Claude、Codex 和 OpenCode 智能体团队的免费开源桌面应用。" }, diff --git a/landing/product-docs/guide/installation.md b/landing/product-docs/guide/installation.md index 93034d7b..80b7c747 100644 --- a/landing/product-docs/guide/installation.md +++ b/landing/product-docs/guide/installation.md @@ -9,7 +9,7 @@ Agent Teams is distributed as a desktop app for macOS, Windows, and Linux. ::: tip Shortest path 1. Download the build for your platform below -2. Launch the app — it detects runtimes and guides provider auth from the UI +2. Launch the app - start with the free model with no auth or connect provider auth from the UI 3. Start the [quickstart](/guide/quickstart) to create your first team Desktop app startup: run `pnpm dev` for the Electron app. Do not start the browser/web dev mode for normal use. @@ -30,16 +30,16 @@ Unsigned or newly published open-source apps can trigger SmartScreen. If you tru ## Requirements -The packaged app is designed for zero-setup onboarding. It guides you through runtime detection and provider authentication from the UI — no manual CLI configuration needed. +The packaged app is designed for zero-setup onboarding. You can start with the free model with no auth - no registration, API keys, or credit card. If you want more models, the app guides runtime detection and provider authentication from the UI. -To use agent runtimes, you need access to at least one provider: +For paid or account-backed models, connect at least one provider: | Provider | Access method | | ------------------ | ------------------------------------------------- | | Claude (Anthropic) | Claude Code CLI login or API key | | Codex (OpenAI) | Codex CLI login or API key | | Gemini (Google) | Google ADC, Gemini CLI, or API key | -| OpenCode | API key for a supported backend (e.g. OpenRouter) | +| OpenCode | Included free model with no auth, or API key for a supported backend (e.g. OpenRouter) | ::: info Gemini is available as a supported provider path. See [Providers and runtimes](/reference/providers-runtimes) for auth options and current status across all providers. diff --git a/landing/product-docs/guide/quickstart.md b/landing/product-docs/guide/quickstart.md index 204ef137..90d09ba8 100644 --- a/landing/product-docs/guide/quickstart.md +++ b/landing/product-docs/guide/quickstart.md @@ -47,7 +47,7 @@ For project conventions and architecture guidance, refer to these canonical file ## 1. Run from source or download -**Download the packaged app** for macOS, Windows, or Linux from the download page — no prerequisites needed. The app guides runtime detection and provider authentication from the UI. +**Download the packaged app** for macOS, Windows, or Linux from the download page - no prerequisites needed. Start with the free model with no auth, or connect provider auth from the UI when you want more models. **Or run from source** for development: @@ -84,7 +84,7 @@ The setup flow auto-detects installed runtimes on your machine. A common first s | -------- | ----------------------------------------------- | | Claude | Claude Code users and existing Anthropic access | | Codex | Codex-native workflows and OpenAI access | -| OpenCode | Multi-model teams and many provider backends | +| OpenCode | Free model with no auth, multi-model teams, and many provider backends | ::: info Gemini is available as a supported provider path. See [Providers and runtimes](/reference/providers-runtimes) for auth options and current provider status. @@ -92,7 +92,7 @@ Gemini is available as a supported provider path. See [Providers and runtimes](/ See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider. -To verify the selected runtime outside the app, check the binary and test auth: +To verify a paid or account-backed runtime outside the app, check the binary and test auth: ```bash # Check that the runtime is installed and on PATH @@ -101,7 +101,7 @@ command -v codex && codex --version command -v opencode && opencode --version ``` -If the command fails, fix the runtime installation or `PATH` first. Team prompts cannot work around a missing binary or missing provider auth. +If the command fails, fix the runtime installation or `PATH` first. Team prompts cannot work around a missing binary or missing provider auth for models that require it. ::: tip If the binary is found but the app reports "not logged in", the environment may differ between your terminal and the app. See the [auth diagnostic log](/guide/troubleshooting#auth-diagnostic-log) to compare them. @@ -162,7 +162,7 @@ Before approving the first task, check three things: | Symptom | Likely cause | Check | | --- | --- | --- | | App does not detect a runtime | Binary not on `PATH`, or app and terminal see different environments | Run `command -v ` in a terminal, then use the same terminal env to launch the app | -| Team launch hangs | Missing provider auth, wrong model string, or runtime binary not found | See [Troubleshooting](/guide/troubleshooting#team-does-not-launch) | +| Team launch hangs | Missing provider auth for a paid/account model, wrong model string, or runtime binary not found | See [Troubleshooting](/guide/troubleshooting#team-does-not-launch) | | OpenCode lane stuck on `registered` | Lane evidence not committed yet, or model string mismatch | Inspect `~/.claude/teams//.opencode-runtime/lanes/` | | Agent replies missing | Runtime delivery retry, parsing, or task attribution issue | Open task logs and check the delivery ledger | | Provider returns 429s | Rate limit reached | Wait for reset or switch model/provider | diff --git a/landing/product-docs/guide/runtime-setup.md b/landing/product-docs/guide/runtime-setup.md index 29b3e658..a7446e28 100644 --- a/landing/product-docs/guide/runtime-setup.md +++ b/landing/product-docs/guide/runtime-setup.md @@ -12,6 +12,7 @@ Agent Teams is a coordination layer. The actual model work runs through supporte | --- | --- | | Already use Claude Code or have Anthropic access | **Claude** - familiar auth, minimal setup | | Use Codex or OpenAI-based workflows | **Codex** - native integration | +| Want to try Agent Teams without signup or API keys | **OpenCode** - use the included free model with no auth | | Want multi-model routing or broad provider coverage | **OpenCode** - most flexible, one config for many backends | | Are not sure which runtime fits | **OpenCode** - covers the most provider options and lets you switch later | @@ -23,7 +24,7 @@ Start with one runtime and one teammate. Confirm one launch works before expandi Before launching a team, make sure: - The runtime binary is installed and on your `PATH`. -- Your provider account has active access to the model you intend to use. +- Your provider account has active access to the model you intend to use, unless you start with the included free OpenCode model with no auth. - The project path exists and is readable. - The app and your terminal use the same home/config environment when you test auth manually. @@ -55,10 +56,10 @@ Gemini is available as a supported provider path with Google ADC (`gcloud auth`) ## Provider access -Agent Teams has no paid tier of its own. You bring the provider access you already have: subscriptions, local runtime auth, or API keys depending on the path you choose. +Agent Teams has no paid tier of its own. You can start with the included free OpenCode model with no auth - no registration, API keys, or credit card. For additional models, bring the provider access you already have: subscriptions, local runtime auth, or API keys depending on the path you choose. - **Claude** and **Codex** paths rely on their respective CLI auth tools. -- **OpenCode** needs provider-specific API keys in a config file (e.g., `openrouter`, `openai`, `anthropic`). +- **OpenCode** can run the included free model with no auth first. Other OpenCode models may need provider-specific API keys in a config file (e.g., `openrouter`, `openai`, `anthropic`). ## Auth configuration @@ -96,7 +97,7 @@ Codex-native launches use Codex account state and model catalog data when availa ### OpenCode -Create or edit `~/.opencode/config.json` (or the equivalent path on your platform) with the provider key you want: +To use the included free model with no auth, select it in the app and launch without provider signup. To use other OpenCode backends, create or edit `~/.opencode/config.json` (or the equivalent path on your platform) with the provider key you want: ```json { diff --git a/landing/product-docs/reference/providers-runtimes.md b/landing/product-docs/reference/providers-runtimes.md index b0d26ece..95282626 100644 --- a/landing/product-docs/reference/providers-runtimes.md +++ b/landing/product-docs/reference/providers-runtimes.md @@ -88,7 +88,7 @@ Recommended patterns: ## Provider costs -Agent Teams is free and open source. Provider usage is governed by the runtime/provider you select: subscription limits, API keys, account auth, rate limits, and provider policies all remain external to the app. +Agent Teams is free and open source. You can start with the included free model with no auth - no registration, API keys, or credit card. Paid or account-backed provider usage is governed by the runtime/provider you select: subscription limits, API keys, account auth, rate limits, and provider policies all remain external to the app. ## Capability checks diff --git a/landing/product-docs/ru/guide/installation.md b/landing/product-docs/ru/guide/installation.md index 1285824f..60eec9b5 100644 --- a/landing/product-docs/ru/guide/installation.md +++ b/landing/product-docs/ru/guide/installation.md @@ -23,16 +23,16 @@ Agent Teams распространяется как desktop-приложение ## Требования -Пакетная сборка рассчитана на zero-setup onboarding. Приложение само помогает с runtime detection и provider authentication — ручная настройка CLI не нужна. +Пакетная сборка рассчитана на zero-setup onboarding. Можно начать с бесплатной модели без авторизации - без регистрации, API-ключей и карты. Если нужны дополнительные модели, приложение само помогает с runtime detection и provider authentication. -Для работы агентных рантаймов нужен доступ хотя бы к одному провайдеру: +Для платных или account-backed моделей подключите хотя бы один провайдер: | Провайдер | Способ доступа | | ------------------ | ---------------------------------------------------------- | | Claude (Anthropic) | Claude Code CLI login или API key | | Codex (OpenAI) | Codex CLI login или API key | | Gemini (Google) | Google ADC, Gemini CLI или API key | -| OpenCode | API key для поддерживаемого бэкенда (например, OpenRouter) | +| OpenCode | Встроенная бесплатная модель без авторизации или API key для поддерживаемого бэкенда (например, OpenRouter) | ::: info Gemini — поддерживаемый провайдер. Варианты auth смотрите в разделе [Провайдеры и рантаймы](/ru/reference/providers-runtimes). diff --git a/landing/product-docs/ru/guide/quickstart.md b/landing/product-docs/ru/guide/quickstart.md index a879f0cc..60d5f9ea 100644 --- a/landing/product-docs/ru/guide/quickstart.md +++ b/landing/product-docs/ru/guide/quickstart.md @@ -14,7 +14,7 @@ lang: ru-RU - **macOS, Windows или Linux** машина - **Git-репозиторий** в качестве проекта (рекомендуется для diff review и worktree isolation) -- Доступ хотя бы к одному провайдеру: Anthropic (Claude), OpenAI (Codex), OpenRouter (OpenCode) или Google (Gemini) +- Бесплатная модель без авторизации для первого запуска или доступ к провайдеру, если нужны дополнительные модели: Anthropic (Claude), OpenAI (Codex), OpenRouter (OpenCode) или Google (Gemini) - Node.js 20+ и pnpm 10+ при запуске из исходников Подробности и ссылки для скачивания — в разделе [Установка](/ru/guide/installation). @@ -24,7 +24,7 @@ lang: ru-RU Скачайте последний релиз под вашу платформу на странице загрузок или в [GitHub releases](https://github.com/777genius/agent-teams-ai/releases). ::: tip -Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру — подробности в разделе [Установка](/ru/guide/installation). +Приложение бесплатное и с открытым кодом. Можно начать с бесплатной модели без авторизации - без регистрации; дополнительные runtime/provider paths могут требовать доступ к провайдеру. Подробности в разделе [Установка](/ru/guide/installation). ::: ::: info @@ -55,7 +55,7 @@ git status --short | -------- | ------------------------------------------------------------------- | | Claude | Если вы уже используете Claude Code или у вас есть Anthropic access | | Codex | Для Codex-native workflows и OpenAI access | -| OpenCode | Для multi-model команд и большого числа provider backends | +| OpenCode | Бесплатная модель без авторизации, multi-model команды и много provider backends | ::: info Gemini — поддерживаемый провайдер. Варианты auth смотрите в разделе [Провайдеры и рантаймы](/ru/reference/providers-runtimes). @@ -63,7 +63,7 @@ Gemini — поддерживаемый провайдер. Варианты aut Подробная настройка каждого провайдера — в разделе [Настройка рантайма](/ru/guide/runtime-setup). -Чтобы проверить выбранный runtime вне приложения, запустите соответствующую команду версии: +Чтобы проверить платный или account-backed runtime вне приложения, запустите соответствующую команду версии: ```bash claude --version @@ -71,7 +71,7 @@ codex --version opencode --version ``` -Если команда падает в терминале, сначала исправьте runtime installation или `PATH`. Team prompts не смогут компенсировать отсутствующий binary или provider auth. +Если команда падает в терминале, сначала исправьте runtime installation или `PATH`. Team prompts не смогут компенсировать отсутствующий binary или provider auth для моделей, которым он нужен. Также можно проверить, что бинарник доступен в `PATH`: @@ -134,7 +134,7 @@ Lead создаёт задачи, назначает работу и коорд | Симптом | Вероятная причина | Что проверить | | --- | --- | --- | | Приложение не видит runtime | Бинарник не в `PATH` или разные окружения у приложения и терминала | Запустите `command -v ` в терминале | -| Запуск команды зависает | Нет provider auth, неверная модель или runtime не найден | Раздел [Диагностика](/ru/guide/troubleshooting#team-does-not-launch) | +| Запуск команды зависает | Нет provider auth для платной/account модели, неверная модель или runtime не найден | Раздел [Диагностика](/ru/guide/troubleshooting#team-does-not-launch) | | OpenCode lane в статусе `registered` | Lane evidence ещё не зафиксирован или несовпадение модели | Проверьте `~/.claude/teams//.opencode-runtime/lanes/` | | Ответы агента не приходят | Runtime delivery retry, parsing или task attribution | Откройте task logs и проверьте delivery ledger | | Провайдер возвращает 429 | Достигнут лимит запросов | Дождитесь сброса или смените модель/провайдера | diff --git a/landing/product-docs/ru/guide/runtime-setup.md b/landing/product-docs/ru/guide/runtime-setup.md index 9e9a2491..f0bb362f 100644 --- a/landing/product-docs/ru/guide/runtime-setup.md +++ b/landing/product-docs/ru/guide/runtime-setup.md @@ -13,7 +13,7 @@ Agent Teams - координационный слой. Работа моделе Перед запуском команды убедитесь, что: - Runtime binary установлен и находится в `PATH`. -- Ваш аккаунт провайдера имеет доступ к выбранной модели. +- Ваш аккаунт провайдера имеет доступ к выбранной модели, если вы не начинаете со встроенной OpenCode-модели без авторизации. - Путь к проекту существует и доступен для чтения. - Приложение и терминал используют одинаковое home/config окружение, когда вы вручную проверяете auth. @@ -45,10 +45,10 @@ Gemini — поддерживаемый провайдер с Google ADC (`gclou ## Доступ к провайдеру -У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: подписка, локальная авторизация рантайма или API-ключи в зависимости от выбранного пути. +У Agent Teams нет своего платного тарифа. Можно начать со встроенной OpenCode-модели без авторизации - без регистрации, API-ключей и карты. Для дополнительных моделей используйте доступ к провайдеру, который у вас уже есть: подписка, локальная авторизация рантайма или API-ключи в зависимости от выбранного пути. - Для **Claude** и **Codex** используется auth соответствующего CLI. -- Для **OpenCode** требуются provider-specific API keys в файле конфигурации (например, `openrouter`, `openai`, `anthropic`). +- **OpenCode** может сначала работать через встроенную бесплатную модель без авторизации. Другие OpenCode-модели могут требовать provider-specific API keys в файле конфигурации (например, `openrouter`, `openai`, `anthropic`). ## Настройка авторизации @@ -86,7 +86,7 @@ Codex-native launches используют Codex account state и model catalog ### OpenCode -Создайте или отредактируйте `~/.opencode/config.json` (или эквивалентный путь на вашей платформе): +Для встроенной бесплатной модели без авторизации достаточно выбрать её в приложении и запустить без регистрации у провайдера. Для других OpenCode backend создайте или отредактируйте `~/.opencode/config.json` (или эквивалентный путь на вашей платформе): ```json { diff --git a/landing/product-docs/ru/reference/providers-runtimes.md b/landing/product-docs/ru/reference/providers-runtimes.md index 14e13f06..30559bb7 100644 --- a/landing/product-docs/ru/reference/providers-runtimes.md +++ b/landing/product-docs/ru/reference/providers-runtimes.md @@ -89,7 +89,7 @@ Contributor-facing границы и canonical implementation guidance смот ## Стоимость providers -Agent Teams бесплатен и open source. Provider usage зависит от выбранного runtime/provider: subscription limits, API keys, account auth, rate limits и provider policies остаются внешними для приложения. +Agent Teams бесплатен и open source. Можно начать со встроенной бесплатной модели без авторизации - без регистрации, API-ключей и карты. Платный или account-backed provider usage зависит от выбранного runtime/provider: subscription limits, API keys, account auth, rate limits и provider policies остаются внешними для приложения. ## Capability checks diff --git a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts index 0537b1ab..09bfa73e 100644 --- a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts +++ b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts @@ -9,13 +9,16 @@ import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { getCachedShellEnv } from '@main/utils/shellEnv'; const CACHE_VERIFY_TTL_MS = 30_000; +const STALE_POSITIVE_CACHE_TTL_MS = 5 * 60_000; const VERSION_CACHE_TTL_MS = 30_000; const BINARY_LAUNCH_VERIFY_TIMEOUT_MS = 3_000; let cachedBinaryPath: string | null | undefined; let cacheVerifiedAt = 0; +let cacheLaunchVerifiedAt = 0; let resolveInFlight: Promise | null = null; let cachedMissHadShellEnv = false; +let cachedPositiveIsStale = false; const versionCache = new Map(); async function fileExists(filePath: string): Promise { @@ -117,29 +120,54 @@ async function verifyBinary(candidate: string): Promise { return null; } +async function canReuseStalePositiveBinary( + candidate: string | null, + launchVerifiedAt: number +): Promise { + if ( + !candidate || + launchVerifiedAt <= 0 || + Date.now() - launchVerifiedAt > STALE_POSITIVE_CACHE_TTL_MS + ) { + return false; + } + + return fileExists(candidate); +} + export class CodexBinaryResolver { static clearCache(): void { cachedBinaryPath = undefined; cacheVerifiedAt = 0; + cacheLaunchVerifiedAt = 0; resolveInFlight = null; cachedMissHadShellEnv = false; + cachedPositiveIsStale = false; versionCache.clear(); } static async resolve(): Promise { + let stalePositiveBinaryPath: string | null = null; + let stalePositiveLaunchVerifiedAt = 0; + if (cachedBinaryPath !== undefined) { if (cachedBinaryPath === null) { if (!cachedMissHadShellEnv && getCachedShellEnv() !== null) { cachedBinaryPath = undefined; cacheVerifiedAt = 0; + cacheLaunchVerifiedAt = 0; cachedMissHadShellEnv = false; + cachedPositiveIsStale = false; } else { const verifiedAppManagedBinaryPath = await resolveVerifiedAppManagedCodexRuntimeBinaryPath(); if (verifiedAppManagedBinaryPath) { + const now = Date.now(); cachedBinaryPath = verifiedAppManagedBinaryPath; - cacheVerifiedAt = Date.now(); + cacheVerifiedAt = now; + cacheLaunchVerifiedAt = now; cachedMissHadShellEnv = false; + cachedPositiveIsStale = false; return verifiedAppManagedBinaryPath; } if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) { @@ -147,22 +175,36 @@ export class CodexBinaryResolver { } cachedBinaryPath = undefined; cacheVerifiedAt = 0; + cacheLaunchVerifiedAt = 0; cachedMissHadShellEnv = false; + cachedPositiveIsStale = false; } } else { - if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) { + const now = Date.now(); + const stalePositiveIsStillAllowed = + !cachedPositiveIsStale || now - cacheLaunchVerifiedAt <= STALE_POSITIVE_CACHE_TTL_MS; + if (now - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS && stalePositiveIsStillAllowed) { return cachedBinaryPath; } - const verified = await verifyBinary(cachedBinaryPath); + const cachedPositiveBinaryPath = cachedBinaryPath; + const cachedPositiveLaunchVerifiedAt = cacheLaunchVerifiedAt; + const verified = await verifyBinary(cachedPositiveBinaryPath); if (verified) { - cacheVerifiedAt = Date.now(); + const verifiedAt = Date.now(); + cacheVerifiedAt = verifiedAt; + cacheLaunchVerifiedAt = verifiedAt; cachedMissHadShellEnv = false; + cachedPositiveIsStale = false; return verified; } + stalePositiveBinaryPath = cachedPositiveBinaryPath; + stalePositiveLaunchVerifiedAt = cachedPositiveLaunchVerifiedAt; cachedBinaryPath = undefined; cacheVerifiedAt = 0; + cacheLaunchVerifiedAt = 0; + cachedPositiveIsStale = false; } } @@ -172,7 +214,20 @@ export class CodexBinaryResolver { }); } - return resolveInFlight; + const resolved = await resolveInFlight; + if ( + !resolved && + (await canReuseStalePositiveBinary(stalePositiveBinaryPath, stalePositiveLaunchVerifiedAt)) + ) { + cachedBinaryPath = stalePositiveBinaryPath; + cacheVerifiedAt = Date.now(); + cacheLaunchVerifiedAt = stalePositiveLaunchVerifiedAt; + cachedMissHadShellEnv = false; + cachedPositiveIsStale = true; + return stalePositiveBinaryPath; + } + + return resolved; } private static async runResolve(): Promise { @@ -187,16 +242,21 @@ export class CodexBinaryResolver { for (const candidate of candidates) { const resolved = await verifyBinary(candidate); if (resolved) { + const now = Date.now(); cachedBinaryPath = resolved; - cacheVerifiedAt = Date.now(); + cacheVerifiedAt = now; + cacheLaunchVerifiedAt = now; cachedMissHadShellEnv = false; + cachedPositiveIsStale = false; return resolved; } } cachedBinaryPath = null; cacheVerifiedAt = Date.now(); + cacheLaunchVerifiedAt = 0; cachedMissHadShellEnv = getCachedShellEnv() !== null; + cachedPositiveIsStale = false; return null; } diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.real.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.real.test.ts new file mode 100644 index 00000000..e1e93fd3 --- /dev/null +++ b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.real.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment node +import { chmod, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@main/utils/cliPathMerge', () => ({ + buildMergedCliPath: () => process.env.PATH ?? '', +})); + +vi.mock('@main/utils/shellEnv', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCachedShellEnv: () => null, + }; +}); + +const originalPath = process.env.PATH; +const originalCodexCliPath = process.env.CODEX_CLI_PATH; +const originalFakeFailFile = process.env.CODEX_FAKE_CODEX_FAIL_FILE; +const describePosix = process.platform === 'win32' ? describe.skip : describe; +const LIVE_CODEX_BINARY_SMOKE = process.env.LIVE_CODEX_BINARY_RESOLVER_SMOKE === '1'; +const describeLive = LIVE_CODEX_BINARY_SMOKE ? describe : describe.skip; +const BASE_TIME_MS = 1_767_225_600_000; + +let tempDirs: string[] = []; + +async function clearResolverCache(): Promise { + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); +} + +async function createFakeCodexBinary(): Promise<{ + binaryPath: string; + failMarkerPath: string; +}> { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'codex-binary-resolver-real-')); + tempDirs.push(tempDir); + const binaryPath = path.join(tempDir, 'codex'); + const failMarkerPath = path.join(tempDir, 'fail'); + await writeFile( + binaryPath, + [ + '#!/bin/sh', + 'if [ -n "$CODEX_FAKE_CODEX_FAIL_FILE" ] && [ -f "$CODEX_FAKE_CODEX_FAIL_FILE" ]; then', + ' echo "fake codex failure" >&2', + ' exit 42', + 'fi', + 'if [ "$1" = "--version" ]; then', + ' echo "codex-cli 99.0.0"', + ' exit 0', + 'fi', + 'echo "unexpected args: $*" >&2', + 'exit 2', + '', + ].join('\n'), + 'utf8' + ); + await chmod(binaryPath, 0o755); + process.env.PATH = tempDir; + return { binaryPath, failMarkerPath }; +} + +afterEach(async () => { + vi.restoreAllMocks(); + process.env.PATH = originalPath; + process.env.CODEX_CLI_PATH = originalCodexCliPath; + process.env.CODEX_FAKE_CODEX_FAIL_FILE = originalFakeFailFile; + await clearResolverCache(); + await Promise.all(tempDirs.map((tempDir) => rm(tempDir, { recursive: true, force: true }))); + tempDirs = []; +}); + +describePosix('CodexBinaryResolver real filesystem/process smoke', () => { + it('resolves an explicit executable through real fs access and execFile', async () => { + const { binaryPath, failMarkerPath } = await createFakeCodexBinary(); + process.env.CODEX_CLI_PATH = binaryPath; + process.env.CODEX_FAKE_CODEX_FAIL_FILE = failMarkerPath; + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(binaryPath); + await expect(CodexBinaryResolver.resolveVersion(binaryPath)).resolves.toBe('99.0.0'); + }); + + it('keeps a recent real executable during transient launch failure, then expires it', async () => { + const { binaryPath, failMarkerPath } = await createFakeCodexBinary(); + process.env.CODEX_CLI_PATH = binaryPath; + process.env.CODEX_FAKE_CODEX_FAIL_FILE = failMarkerPath; + const nowSpy = vi.spyOn(Date, 'now'); + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + nowSpy.mockReturnValue(BASE_TIME_MS); + await expect(CodexBinaryResolver.resolve()).resolves.toBe(binaryPath); + + await writeFile(failMarkerPath, 'fail', 'utf8'); + nowSpy.mockReturnValue(BASE_TIME_MS + 30_001); + await expect(CodexBinaryResolver.resolve()).resolves.toBe(binaryPath); + + nowSpy.mockReturnValue(BASE_TIME_MS + 300_001); + await expect(CodexBinaryResolver.resolve()).resolves.toBeNull(); + }); + + it('does not keep a recent real executable after it is removed', async () => { + const { binaryPath, failMarkerPath } = await createFakeCodexBinary(); + process.env.CODEX_CLI_PATH = binaryPath; + process.env.CODEX_FAKE_CODEX_FAIL_FILE = failMarkerPath; + const nowSpy = vi.spyOn(Date, 'now'); + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + nowSpy.mockReturnValue(BASE_TIME_MS); + await expect(CodexBinaryResolver.resolve()).resolves.toBe(binaryPath); + + await unlink(binaryPath); + nowSpy.mockReturnValue(BASE_TIME_MS + 30_001); + await expect(CodexBinaryResolver.resolve()).resolves.toBeNull(); + }); +}); + +describeLive('CodexBinaryResolver live local Codex smoke', () => { + it('resolves and versions the current local Codex binary', async () => { + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + const binaryPath = await CodexBinaryResolver.resolve(); + expect(binaryPath).toEqual(expect.any(String)); + const version = await CodexBinaryResolver.resolveVersion(binaryPath); + expect(version).toEqual(expect.any(String)); + }); +}); diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts index a1d4c07c..3dbc9481 100644 --- a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts +++ b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts @@ -327,6 +327,137 @@ describe('CodexBinaryResolver', () => { expect(buildEnrichedEnvMock).toHaveBeenCalledWith(codexShim); }); + it('reuses a recent known-good binary when revalidation transiently fails', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + setPlatform('darwin'); + process.env.PATH = '/usr/local/bin:/usr/bin:/bin'; + const codexShim = path.posix.join('/usr/local/bin', 'codex'); + let canLaunch = true; + + accessMock.mockImplementation((filePath) => { + if (filePath === codexShim) { + return Promise.resolve(); + } + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + execCliMock.mockImplementation(() => { + if (canLaunch) { + return Promise.resolve({ stdout: 'codex-cli 0.130.0', stderr: '' }); + } + return Promise.reject(new Error('codex --version timed out')); + }); + + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim); + + canLaunch = false; + vi.advanceTimersByTime(30_001); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim); + }); + + it('expires stale known-good reuse from the last real launch verification', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + setPlatform('darwin'); + process.env.PATH = '/usr/local/bin:/usr/bin:/bin'; + const codexShim = path.posix.join('/usr/local/bin', 'codex'); + let canLaunch = true; + + accessMock.mockImplementation((filePath) => { + if (filePath === codexShim) { + return Promise.resolve(); + } + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + execCliMock.mockImplementation(() => { + if (canLaunch) { + return Promise.resolve({ stdout: 'codex-cli 0.130.0', stderr: '' }); + } + return Promise.reject(new Error('codex --version timed out')); + }); + + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim); + + canLaunch = false; + vi.advanceTimersByTime(290_000); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim); + + vi.advanceTimersByTime(10_001); + + await expect(CodexBinaryResolver.resolve()).resolves.toBeNull(); + }); + + it('prefers a newly resolved binary over stale known-good reuse', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + setPlatform('darwin'); + const oldCodexShim = path.posix.join('/old/bin', 'codex'); + const newCodexShim = path.posix.join('/new/bin', 'codex'); + process.env.PATH = '/old/bin:/usr/bin:/bin'; + let oldCanLaunch = true; + + accessMock.mockImplementation((filePath) => { + if (filePath === oldCodexShim || filePath === newCodexShim) { + return Promise.resolve(); + } + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + execCliMock.mockImplementation((binaryPath) => { + if (binaryPath === oldCodexShim) { + if (oldCanLaunch) { + return Promise.resolve({ stdout: 'codex-cli 0.130.0', stderr: '' }); + } + return Promise.reject(new Error('old codex --version timed out')); + } + return Promise.resolve({ stdout: 'codex-cli 0.131.0', stderr: '' }); + }); + + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(oldCodexShim); + + oldCanLaunch = false; + process.env.PATH = '/new/bin:/usr/bin:/bin'; + vi.advanceTimersByTime(30_001); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(newCodexShim); + }); + + it('does not reuse a recent known-good binary after the file disappears', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + setPlatform('darwin'); + process.env.PATH = '/usr/local/bin:/usr/bin:/bin'; + const codexShim = path.posix.join('/usr/local/bin', 'codex'); + let filePresent = true; + + accessMock.mockImplementation((filePath) => { + if (filePath === codexShim && filePresent) { + return Promise.resolve(); + } + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim); + + filePresent = false; + vi.advanceTimersByTime(30_001); + + await expect(CodexBinaryResolver.resolve()).resolves.toBeNull(); + }); + it('uses enriched env for Codex version probes', async () => { setPlatform('darwin'); const codexShim = path.posix.join('/usr/local/bin', 'codex'); diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 4e89a2af..c95de073 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -498,20 +498,12 @@ export class TeamMemberRuntimeAdvisoryService { getOpenCodeRuntimeDeliveryRecordTimeMs(right) - getOpenCodeRuntimeDeliveryRecordTimeMs(left) ); - const latestSuccess = ordered.find(isTerminalSuccessfulOpenCodeDeliveryRecord); const latestError = ordered.find((record) => { return this.isOpenCodeDeliveryAdvisoryCandidate(record, now); }); if (!latestError) { return null; } - if ( - latestSuccess && - getOpenCodeRuntimeDeliveryRecordTimeMs(latestSuccess) > - getOpenCodeRuntimeDeliveryRecordTimeMs(latestError) - ) { - return null; - } const decision = decideOpenCodeRuntimeDeliveryAdvisory({ record: latestError, diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy.ts index de83fcf6..ebbc0e04 100644 --- a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy.ts +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryAdvisoryPolicy.ts @@ -12,6 +12,7 @@ import type { } from '@shared/types'; export const OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS = 120_000; +const OPENCODE_RUNTIME_DELIVERY_PROOF_TIMESTAMP_SKEW_MS = 5_000; export interface OpenCodeRuntimeDeliveryProofSnapshot { latestSuccessAt?: number; @@ -156,14 +157,20 @@ export function hasSupersedingOpenCodeRuntimeDeliveryProof(input: { if (!proof) { return false; } - const recordTime = getOpenCodeRuntimeDeliveryRecordTimeMs(input.record); - if (typeof proof.latestSuccessAt === 'number' && proof.latestSuccessAt > recordTime) { + const promptTime = getOpenCodeRuntimeDeliveryPromptTimeMs(input.record); + const isPromptTimeEligible = (proofAt: number): boolean => { + if (!Number.isFinite(proofAt) || proofAt <= 0) { + return false; + } + if (!Number.isFinite(promptTime) || promptTime <= 0) { + return true; + } + return proofAt + OPENCODE_RUNTIME_DELIVERY_PROOF_TIMESTAMP_SKEW_MS >= promptTime; + }; + if (typeof proof.visibleReplyAt === 'number' && isPromptTimeEligible(proof.visibleReplyAt)) { return true; } - if (typeof proof.visibleReplyAt === 'number' && proof.visibleReplyAt > 0) { - return true; - } - if (typeof proof.taskProgressAt === 'number' && proof.taskProgressAt > 0) { + if (typeof proof.taskProgressAt === 'number' && isPromptTimeEligible(proof.taskProgressAt)) { return true; } return false; diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts index b3e90652..7ebc877f 100644 --- a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -101,6 +101,10 @@ export interface OpenCodeTeamLaunchReadinessServiceOptions { const OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC = 'OpenCode runtime binary is not installed or not reachable by launch preflight.'; +const OPENCODE_UNAUTHENTICATED_FREE_MODEL_DIAGNOSTIC = + 'No connected OpenCode provider found. Proceeding with a free OpenCode model route that does not require provider authentication.'; +const OPENCODE_UNAUTHENTICATED_PAID_MODEL_DIAGNOSTIC = + 'No connected OpenCode provider found. Choose a free OpenCode model such as Big Pickle, or connect a provider in OpenCode for provider-backed models.'; export class OpenCodeTeamLaunchReadinessService { constructor( @@ -132,18 +136,14 @@ export class OpenCodeTeamLaunchReadinessService { }); } - if (!inventory.authenticated || inventory.connectedProviders.length === 0) { - return readiness({ - state: 'not_authenticated', - inventory, - modelId: input.selectedModel, - diagnostics: appendDiagnostics(inventory.diagnostics, [ - 'No connected OpenCode providers found', - ]), - }); - } - - const modelId = input.selectedModel ?? inventory.models[0] ?? null; + const explicitModelId = input.selectedModel?.trim() || null; + const hasConnectedProvider = + inventory.authenticated && inventory.connectedProviders.length > 0; + const modelId = + explicitModelId ?? + (!hasConnectedProvider + ? (inventory.models.find(isFreeOpenCodeModelRoute) ?? inventory.models[0] ?? null) + : (inventory.models[0] ?? null)); if (!modelId) { return readiness({ state: 'model_unavailable', @@ -153,6 +153,20 @@ export class OpenCodeTeamLaunchReadinessService { }); } + const usingFreeModelWithoutProvider = + !hasConnectedProvider && isFreeOpenCodeModelRoute(modelId); + + if (!hasConnectedProvider && !usingFreeModelWithoutProvider) { + return readiness({ + state: 'not_authenticated', + inventory, + modelId, + diagnostics: appendDiagnostics(inventory.diagnostics, [ + OPENCODE_UNAUTHENTICATED_PAID_MODEL_DIAGNOSTIC, + ]), + }); + } + const capabilities = await this.capabilities.detect({ projectPath: input.projectPath, inventory, @@ -241,7 +255,11 @@ export class OpenCodeTeamLaunchReadinessService { runtimeStoreReadiness, supportLevel: support.supportLevel, launchAllowed: true, - diagnostics: inventory.diagnostics, + diagnostics: usingFreeModelWithoutProvider + ? appendDiagnostics(inventory.diagnostics, [ + OPENCODE_UNAUTHENTICATED_FREE_MODEL_DIAGNOSTIC, + ]) + : inventory.diagnostics, }); } catch (error) { return readiness({ @@ -254,6 +272,16 @@ export class OpenCodeTeamLaunchReadinessService { } } +function isFreeOpenCodeModelRoute(modelId: string): boolean { + const normalized = modelId.trim().toLowerCase(); + return ( + normalized === 'opencode/big-pickle' || + normalized.includes(':free') || + normalized.endsWith('-free') || + normalized.endsWith('/free') + ); +} + function readiness(input: { state: OpenCodeTeamLaunchReadinessState; inventory: OpenCodeRuntimeInventory | null; diff --git a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts index f9cf2ef3..06caec45 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts @@ -18,14 +18,14 @@ const REVIEW_TOUCH_TOOLS = new Set(['review_start', 'task_add_comment']); const ONE_MINUTE_MS = 60_000; const WORK_THRESHOLDS_MS: Record = { - turn_ended_after_touch: 8 * ONE_MINUTE_MS, - touch_then_other_turns: 10 * ONE_MINUTE_MS, - mid_turn_after_touch: 20 * ONE_MINUTE_MS, + turn_ended_after_touch: 4 * ONE_MINUTE_MS, + touch_then_other_turns: 5 * ONE_MINUTE_MS, + mid_turn_after_touch: 10 * ONE_MINUTE_MS, }; const REVIEW_THRESHOLDS_MS: Record = { - turn_ended_after_touch: 10 * ONE_MINUTE_MS, - touch_then_other_turns: 10 * ONE_MINUTE_MS, - mid_turn_after_touch: 25 * ONE_MINUTE_MS, + turn_ended_after_touch: 5 * ONE_MINUTE_MS, + touch_then_other_turns: 6 * ONE_MINUTE_MS, + mid_turn_after_touch: 12 * ONE_MINUTE_MS, }; function skip( diff --git a/src/main/services/team/stallMonitor/featureGates.ts b/src/main/services/team/stallMonitor/featureGates.ts index d3c2c551..90db02a7 100644 --- a/src/main/services/team/stallMonitor/featureGates.ts +++ b/src/main/services/team/stallMonitor/featureGates.ts @@ -44,7 +44,7 @@ export function isTeamTaskStallAlertsEnabled(): boolean { } export function getTeamTaskStallScanIntervalMs(): number { - return readInt(process.env.CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS, 60_000); + return readInt(process.env.CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS, 30_000); } export function getTeamTaskStallStartupGraceMs(): number { @@ -52,10 +52,10 @@ export function getTeamTaskStallStartupGraceMs(): number { } export function getTeamTaskStallActivationGraceMs(): number { - return readInt(process.env.CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS, 120_000); + return readInt(process.env.CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS, 60_000); } export function getOpenCodeWeakStartStallThresholdMs(): number { // Shorter OpenCode threshold for "started work" comments that do not contain concrete progress. - return readInt(process.env.CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS, 120_000); + return readInt(process.env.CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS, 100_000); } diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 741aa188..cc138fc1 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -328,6 +328,38 @@ function getOpenCodeModelPricingInfo( }; } +function isFreeOpenCodeModelRoute(model: string): boolean { + const normalized = model.trim().toLowerCase(); + return ( + normalized === 'opencode/big-pickle' || + normalized.includes(':free') || + normalized.endsWith('-free') || + normalized.endsWith('/free') + ); +} + +function hasFreeOpenCodeModelRoute(providerStatus: CliProviderStatus | null | undefined): boolean { + if (providerStatus?.providerId !== 'opencode') { + return false; + } + + if (providerStatus.models.some(isFreeOpenCodeModelRoute)) { + return true; + } + + return ( + providerStatus.modelCatalog?.models.some((model) => { + const badgeLabel = model.badgeLabel?.trim().toLowerCase(); + return ( + model.metadata?.free === true || + badgeLabel === 'free' || + isFreeOpenCodeModelRoute(model.launchModel) || + isFreeOpenCodeModelRoute(model.id) + ); + }) ?? false + ); +} + const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.'; export const OPENCODE_ONE_SHOT_DISABLED_REASON = 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic or Codex for one-shot schedules.'; @@ -343,7 +375,7 @@ function getOpenCodeReadinessBadgeLabel( return 'Install'; } if (!providerStatus.authenticated) { - return 'Auth'; + return 'Free'; } return 'Setup'; } @@ -353,10 +385,26 @@ function getOpenCodeReadinessSummary(providerStatus: CliProviderStatus | null | return 'OpenCode status: checking runtime'; } + const runtimeReady = providerStatus.supported; + const hasFreeModelRoute = hasFreeOpenCodeModelRoute(providerStatus); + let readinessSummary = 'team launch blocked'; + if (runtimeReady) { + if (!providerStatus.authenticated) { + readinessSummary = hasFreeModelRoute + ? 'provider connection optional' + : 'provider-backed models need setup'; + } else if (providerStatus.capabilities.teamLaunch) { + readinessSummary = 'team launch ready'; + } + } const parts = [ - providerStatus.supported ? 'runtime detected' : 'runtime missing', - providerStatus.authenticated ? 'provider connected' : 'provider not connected', - providerStatus.capabilities.teamLaunch ? 'team launch ready' : 'team launch blocked', + runtimeReady ? 'runtime detected' : 'runtime missing', + runtimeReady && !providerStatus.authenticated && hasFreeModelRoute + ? 'free models available without auth' + : providerStatus.authenticated + ? 'provider connected' + : 'provider not connected', + readinessSummary, ]; return `OpenCode status: ${parts.join(' · ')}`; } @@ -369,7 +417,10 @@ function getOpenCodeReadinessMessage(providerStatus: CliProviderStatus | null | return 'OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status.'; } if (!providerStatus.authenticated) { - return 'OpenCode is detected, but it does not have a connected provider. Connect a provider in OpenCode, then refresh provider status.'; + if (hasFreeOpenCodeModelRoute(providerStatus)) { + return 'OpenCode is detected. You can use free OpenCode models such as Big Pickle without connecting a provider. Connect a provider only when you want provider-backed models.'; + } + return 'OpenCode is detected, but no free OpenCode model is listed yet. Refresh provider status, or connect a provider in OpenCode for provider-backed models.'; } if (!providerStatus.capabilities.teamLaunch) { return 'OpenCode is installed and authenticated, but Agent Teams launch readiness is blocked.'; @@ -702,14 +753,7 @@ export const TeamModelSelector: React.FC = ({ 'OpenCode runtime is not installed.' ); } - if (!providerStatus.authenticated) { - return ( - providerStatus.detailMessage ?? - providerStatus.statusMessage ?? - 'OpenCode has no connected provider.' - ); - } - if (!providerStatus.capabilities.teamLaunch) { + if (providerStatus.authenticated && !providerStatus.capabilities.teamLaunch) { return ( providerStatus.detailMessage ?? providerStatus.statusMessage ?? @@ -1104,17 +1148,30 @@ export const TeamModelSelector: React.FC = ({ reason: activeProviderDisabledReason, actionLabel: null, } - : canActivateInspectedOpenCode + : effectiveProviderId === 'opencode' && + runtimeProviderStatus?.supported === true && + runtimeProviderStatus.authenticated === false ? { - tone: 'ready' as const, - title: 'OpenCode is ready', + tone: 'warning' as const, + title: hasFreeOpenCodeModelRoute(runtimeProviderStatus) + ? 'OpenCode free models are available' + : 'OpenCode provider is not connected', summary: getOpenCodeReadinessSummary(runtimeProviderStatus), - message: - 'OpenCode passed provider readiness. Select it to use OpenCode models for this team.', + message: getOpenCodeReadinessMessage(runtimeProviderStatus), reason: null, - actionLabel: 'Use OpenCode', + actionLabel: null, } - : null; + : canActivateInspectedOpenCode + ? { + tone: 'ready' as const, + title: 'OpenCode is ready', + summary: getOpenCodeReadinessSummary(runtimeProviderStatus), + message: + 'OpenCode passed provider readiness. Select it to use OpenCode models for this team.', + reason: null, + actionLabel: 'Use OpenCode', + } + : null; const activeProviderNotice = providerNoticeById?.[effectiveProviderId] ?? null; const getModelAdvisoryBadgeLabel = (reason: string | null): string => reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note'; diff --git a/test/main/services/team/OpenCodeRuntimeDeliveryAdvisoryPolicy.test.ts b/test/main/services/team/OpenCodeRuntimeDeliveryAdvisoryPolicy.test.ts index 10312462..98220ce8 100644 --- a/test/main/services/team/OpenCodeRuntimeDeliveryAdvisoryPolicy.test.ts +++ b/test/main/services/team/OpenCodeRuntimeDeliveryAdvisoryPolicy.test.ts @@ -173,4 +173,56 @@ describe('OpenCodeRuntimeDeliveryAdvisoryPolicy', () => { }) ).toMatchObject({ action: 'suppress' }); }); + + it('does not suppress terminal failures with stale visible proof before the prompt window', () => { + const record = makeRecord({}); + + expect( + decideOpenCodeRuntimeDeliveryAdvisory({ + record, + proof: { + visibleReplyAt: Date.parse(record.inboxTimestamp) - 6_000, + visibleReplyMessageId: 'old-reply', + visibleReplyInbox: 'user', + }, + now: Date.parse(record.failedAt!) + OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS + 1, + }) + ).toMatchObject({ + action: 'surface', + severity: 'error', + }); + }); + + it('does not suppress terminal failures with only unrelated later delivery success', () => { + const record = makeRecord({}); + + expect( + decideOpenCodeRuntimeDeliveryAdvisory({ + record, + proof: { + latestSuccessAt: Date.parse(record.failedAt!) + 60_000, + }, + now: Date.parse(record.failedAt!) + OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS + 1, + }) + ).toMatchObject({ + action: 'surface', + severity: 'error', + }); + }); + + it('accepts visible proof inside the prompt timestamp skew window', () => { + const record = makeRecord({}); + + expect( + decideOpenCodeRuntimeDeliveryAdvisory({ + record, + proof: { + visibleReplyAt: Date.parse(record.inboxTimestamp) - 4_000, + visibleReplyMessageId: 'nearby-reply', + visibleReplyInbox: 'user', + }, + now: Date.parse(record.failedAt!) + OPENCODE_RUNTIME_DELIVERY_GENERIC_PROOF_GRACE_MS + 1, + }) + ).toMatchObject({ action: 'suppress' }); + }); }); diff --git a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts index e86e7afa..d4efa6fb 100644 --- a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts +++ b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it, vi } from 'vitest'; import { createEmptyEndpointMap } from '../../../../src/main/services/team/opencode/capabilities/OpenCodeApiCapabilities'; import { REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; import { - OpenCodeTeamLaunchReadinessService, type OpenCodeApiCapabilityPort, - type OpenCodeModelExecutionProbePort, type OpenCodeMcpToolProofPort, + type OpenCodeModelExecutionProbePort, type OpenCodeRuntimeInventory, type OpenCodeRuntimeInventoryPort, type OpenCodeRuntimeStoreReadinessPort, + OpenCodeTeamLaunchReadinessService, } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness'; import type { @@ -38,7 +38,66 @@ describe('OpenCodeTeamLaunchReadinessService', () => { expect(ports.mcpTools.prove).not.toHaveBeenCalled(); }); - it('blocks unauthenticated OpenCode even when the binary is installed', async () => { + it('allows unauthenticated OpenCode when the selected model is a free route', async () => { + const ports = createPorts({ + inventory: { + authenticated: false, + connectedProviders: [], + models: ['opencode/big-pickle'], + }, + }); + + await expect( + service(ports).check(readinessInput({ selectedModel: 'opencode/big-pickle' })) + ).resolves.toMatchObject({ + state: 'ready', + launchAllowed: true, + modelId: 'opencode/big-pickle', + diagnostics: [ + 'No connected OpenCode provider found. Proceeding with a free OpenCode model route that does not require provider authentication.', + ], + }); + expect(ports.capabilities.detect).toHaveBeenCalled(); + expect(ports.mcpTools.prove).toHaveBeenCalled(); + }); + + it('uses the first free OpenCode model for unauthenticated default selection', async () => { + const ports = createPorts({ + inventory: { + authenticated: false, + connectedProviders: [], + models: ['openai/gpt-5.4-mini', 'opencode/big-pickle'], + }, + }); + + await expect(service(ports).check(readinessInput({ selectedModel: null }))).resolves.toMatchObject({ + state: 'ready', + launchAllowed: true, + modelId: 'opencode/big-pickle', + }); + expect(ports.capabilities.detect).toHaveBeenCalled(); + expect(ports.mcpTools.prove).toHaveBeenCalled(); + }); + + it('does not replace an explicit unauthenticated provider-backed model with a free route', async () => { + const ports = createPorts({ + inventory: { + authenticated: false, + connectedProviders: [], + models: ['opencode/big-pickle', 'openai/gpt-5.4-mini'], + }, + }); + + await expect(service(ports).check(readinessInput())).resolves.toMatchObject({ + state: 'not_authenticated', + launchAllowed: false, + modelId: 'openai/gpt-5.4-mini', + }); + expect(ports.capabilities.detect).not.toHaveBeenCalled(); + expect(ports.mcpTools.prove).not.toHaveBeenCalled(); + }); + + it('blocks unauthenticated OpenCode when the selected model needs a provider', async () => { const ports = createPorts({ inventory: { authenticated: false, connectedProviders: [] }, }); @@ -47,8 +106,12 @@ describe('OpenCodeTeamLaunchReadinessService', () => { state: 'not_authenticated', launchAllowed: false, opencodeVersion: '1.14.19', - diagnostics: ['No connected OpenCode providers found'], + diagnostics: [ + 'No connected OpenCode provider found. Choose a free OpenCode model such as Big Pickle, or connect a provider in OpenCode for provider-backed models.', + ], }); + expect(ports.capabilities.detect).not.toHaveBeenCalled(); + expect(ports.mcpTools.prove).not.toHaveBeenCalled(); }); it('blocks unsupported versions before MCP and model probes', async () => { diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index c891e7b4..3221df5b 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -1,13 +1,19 @@ +import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import * as fs from 'fs/promises'; - import { TeamMemberRuntimeAdvisoryService } from '../../../../src/main/services/team/TeamMemberRuntimeAdvisoryService'; import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; -import type { MemberRuntimeAdvisory, ResolvedTeamMember } from '../../../../src/shared/types/team'; +import type { OpenCodePromptDeliveryLedgerRecord } from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger'; +import type { + InboxMessage, + MemberRuntimeAdvisory, + ResolvedTeamMember, + TaskRef, + TeamTask, +} from '../../../../src/shared/types/team'; interface Deferred { promise: Promise; @@ -15,6 +21,11 @@ interface Deferred { reject: (reason?: unknown) => void; } +interface TeamMemberRuntimeAdvisoryServiceTestAccess { + extractApiRetryAdvisory(line: string): MemberRuntimeAdvisory | null; + extractApiErrorAdvisory(line: string, observedAtMs: number): MemberRuntimeAdvisory | null; +} + function createDeferred(): Deferred { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -43,6 +54,12 @@ function buildRetryingAdvisory(label: string): MemberRuntimeAdvisory { }; } +function serviceTestAccess( + service: TeamMemberRuntimeAdvisoryService +): TeamMemberRuntimeAdvisoryServiceTestAccess { + return service as unknown as TeamMemberRuntimeAdvisoryServiceTestAccess; +} + function createStubbedServiceHarness() { const logsFinder = { findMemberLogs: vi.fn(async (_teamName: string, memberName: string) => [ @@ -67,6 +84,119 @@ function createStubbedServiceHarness() { return { service, logsFinder, advisoryByFilePath, readRecentApiRetryAdvisory }; } +function buildOpenCodeDeliveryRecord( + overrides: Partial +): OpenCodePromptDeliveryLedgerRecord { + const now = '2026-05-19T12:19:04.252Z'; + return { + id: 'opencode-prompt:test', + teamName: 'relay-release', + memberName: 'tom', + laneId: 'secondary:opencode:tom', + runId: 'run-1', + runtimeSessionId: 'session-1', + inboxMessageId: 'assignment-1', + inboxTimestamp: '2026-05-19T12:14:56.227Z', + source: 'watcher', + messageKind: null, + replyRecipient: 'team-lead', + actionMode: null, + taskRefs: [], + payloadHash: 'sha256:test', + status: 'failed_terminal', + responseState: 'reconcile_failed', + attempts: 3, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: '2026-05-19T12:19:04.203Z', + lastObservedAt: '2026-05-19T12:18:44.306Z', + acceptedAt: '2026-05-19T12:15:47.042Z', + respondedAt: '2026-05-19T12:16:09.712Z', + failedAt: now, + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: 'msg_before', + postPromptCursor: null, + deliveredUserMessageId: 'msg_user', + observedAssistantMessageId: 'msg_assistant', + observedAssistantPreview: null, + observedToolCallNames: [], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: 'OpenCode bridge command timed out', + diagnostics: [ + 'OpenCode prompt_async accepted; response observation will continue through durable app-side ledger reconciliation.', + 'project_behavior_changed', + 'opencode_session_stale_observe_scheduled_after_accepted_prompt', + 'OpenCode bridge command timed out', + ], + createdAt: '2026-05-19T12:14:56.474Z', + updatedAt: now, + ...overrides, + }; +} + +async function writeOpenCodeDeliveryFixture(input: { + baseDir: string; + teamName: string; + laneId: string; + records: OpenCodePromptDeliveryLedgerRecord[]; + inboxes?: Record; + tasks?: TeamTask[]; +}): Promise { + const teamDir = path.join(input.baseDir, 'teams', input.teamName); + const laneDir = path.join( + teamDir, + '.opencode-runtime', + 'lanes', + encodeURIComponent(input.laneId) + ); + await fs.mkdir(laneDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, '.opencode-runtime', 'lanes.json'), + JSON.stringify({ + version: 1, + updatedAt: input.records[0]?.updatedAt ?? new Date().toISOString(), + lanes: { + [input.laneId]: { + laneId: input.laneId, + state: 'active', + updatedAt: input.records[0]?.updatedAt ?? new Date().toISOString(), + }, + }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(laneDir, 'opencode-prompt-delivery-ledger.json'), + JSON.stringify({ + schemaVersion: 1, + updatedAt: input.records[0]?.updatedAt ?? new Date().toISOString(), + data: input.records, + }), + 'utf8' + ); + + if (input.inboxes) { + const inboxDir = path.join(teamDir, 'inboxes'); + await fs.mkdir(inboxDir, { recursive: true }); + for (const [inboxName, messages] of Object.entries(input.inboxes)) { + await fs.writeFile(path.join(inboxDir, `${inboxName}.json`), JSON.stringify(messages), 'utf8'); + } + } + + if (input.tasks) { + const tasksDir = path.join(input.baseDir, 'tasks', input.teamName); + await fs.mkdir(tasksDir, { recursive: true }); + for (const task of input.tasks) { + await fs.writeFile(path.join(tasksDir, `${task.id}.json`), JSON.stringify(task), 'utf8'); + } + } +} + describe('TeamMemberRuntimeAdvisoryService', () => { let tmpDir: string | null = null; @@ -175,7 +305,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => { ['backend_error', 'Unexpected backend blew up during request processing.'], ] as const)('classifies %s retry causes from api_error messages', async (expected, message) => { const service = new TeamMemberRuntimeAdvisoryService({} as never); - const advisory = (service as any).extractApiRetryAdvisory( + const advisory = serviceTestAccess(service).extractApiRetryAdvisory( JSON.stringify({ type: 'system', subtype: 'api_error', @@ -196,7 +326,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => { it('classifies missing api_error message text as unknown', () => { const service = new TeamMemberRuntimeAdvisoryService({} as never); - const advisory = (service as any).extractApiRetryAdvisory( + const advisory = serviceTestAccess(service).extractApiRetryAdvisory( JSON.stringify({ type: 'system', subtype: 'api_error', @@ -211,7 +341,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => { it('keeps terminal API errors visible after retries stop', () => { const service = new TeamMemberRuntimeAdvisoryService({} as never); const observedAt = '2099-04-09T10:00:00.000Z'; - const advisory = (service as any).extractApiErrorAdvisory( + const advisory = serviceTestAccess(service).extractApiErrorAdvisory( JSON.stringify({ type: 'assistant', timestamp: observedAt, @@ -241,7 +371,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => { it('treats Claude Code account access failures as auth errors', () => { const service = new TeamMemberRuntimeAdvisoryService({} as never); const observedAt = '2099-04-09T10:00:00.000Z'; - const advisory = (service as any).extractApiErrorAdvisory( + const advisory = serviceTestAccess(service).extractApiErrorAdvisory( JSON.stringify({ type: 'assistant', timestamp: observedAt, @@ -558,6 +688,288 @@ describe('TeamMemberRuntimeAdvisoryService', () => { }); }); + it('suppresses stale OpenCode reconcile advisories after a later relayed runtime reply exists', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-19T12:26:30.000Z')); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'relay-release'; + const laneId = 'secondary:opencode:tom'; + const taskRef: TaskRef = { + teamName, + taskId: 'fb72209d-ea5b-45e0-9380-fe2e8235206e', + displayId: 'fb72209d', + }; + await writeOpenCodeDeliveryFixture({ + baseDir: tmpDir, + teamName, + laneId, + records: [ + buildOpenCodeDeliveryRecord({ + teamName, + laneId, + taskRefs: [taskRef], + }), + ], + inboxes: { + 'team-lead': [ + { + from: 'tom', + to: 'team-lead', + text: '#fb72209d done. API docs regenerated, diff empty.', + timestamp: '2026-05-19T12:25:56.384Z', + read: true, + relayOfMessageId: 'assignment-1', + source: 'runtime_delivery', + messageId: 'visible-reply-1', + taskRefs: [taskRef], + summary: '#fb72209d done', + }, + ], + }, + }); + + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(async () => []), + }); + const advisory = await service.getMemberAdvisory(teamName, 'tom'); + + expect(advisory).toBeNull(); + }); + + it('keeps stale OpenCode reconcile advisories visible until persisted proof exists', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-19T12:26:30.000Z')); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'relay-release'; + const laneId = 'secondary:opencode:tom'; + await writeOpenCodeDeliveryFixture({ + baseDir: tmpDir, + teamName, + laneId, + records: [buildOpenCodeDeliveryRecord({ teamName, laneId })], + }); + + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(async () => []), + }); + const advisory = await service.getMemberAdvisory(teamName, 'tom'); + + expect(advisory).toMatchObject({ + kind: 'api_error', + reasonCode: 'backend_error', + }); + expect(advisory?.message).toContain( + 'opencode_session_stale_observe_scheduled_after_accepted_prompt' + ); + }); + + it('keeps stale OpenCode advisories visible after unrelated later delivery success', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-19T12:30:00.000Z')); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'relay-release'; + const laneId = 'secondary:opencode:tom'; + await writeOpenCodeDeliveryFixture({ + baseDir: tmpDir, + teamName, + laneId, + records: [ + buildOpenCodeDeliveryRecord({ teamName, laneId }), + buildOpenCodeDeliveryRecord({ + id: 'opencode-prompt:later-success', + teamName, + laneId, + inboxMessageId: 'later-assignment', + inboxTimestamp: '2026-05-19T12:24:00.000Z', + status: 'responded', + responseState: 'responded_visible_message', + taskRefs: [ + { + teamName, + taskId: 'different-task', + displayId: 'different', + }, + ], + failedAt: null, + respondedAt: '2026-05-19T12:25:30.000Z', + lastObservedAt: '2026-05-19T12:25:30.000Z', + updatedAt: '2026-05-19T12:25:45.000Z', + inboxReadCommittedAt: '2026-05-19T12:25:45.000Z', + visibleReplyMessageId: 'later-visible-reply', + visibleReplyInbox: 'team-lead', + visibleReplyCorrelation: 'relayOfMessageId', + lastReason: null, + diagnostics: [], + }), + ], + }); + + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(async () => []), + }); + const advisory = await service.getMemberAdvisory(teamName, 'tom'); + + expect(advisory).toMatchObject({ + kind: 'api_error', + reasonCode: 'backend_error', + }); + }); + + it('does not suppress stale OpenCode advisories for same-member replies without relay or task proof', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-19T12:26:30.000Z')); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'relay-release'; + const laneId = 'secondary:opencode:tom'; + const taskRef: TaskRef = { + teamName, + taskId: 'fb72209d-ea5b-45e0-9380-fe2e8235206e', + displayId: 'fb72209d', + }; + await writeOpenCodeDeliveryFixture({ + baseDir: tmpDir, + teamName, + laneId, + records: [ + buildOpenCodeDeliveryRecord({ + teamName, + laneId, + taskRefs: [taskRef], + }), + ], + inboxes: { + 'team-lead': [ + { + from: 'tom', + to: 'team-lead', + text: 'Done on a different prompt.', + timestamp: '2026-05-19T12:25:56.384Z', + read: true, + source: 'runtime_delivery', + messageId: 'unrelated-reply', + summary: 'Done', + }, + ], + }, + }); + + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(async () => []), + }); + const advisory = await service.getMemberAdvisory(teamName, 'tom'); + + expect(advisory).toMatchObject({ + kind: 'api_error', + reasonCode: 'backend_error', + }); + }); + + it('does not suppress stale OpenCode advisories for task progress from another member', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-19T12:26:30.000Z')); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'relay-release'; + const laneId = 'secondary:opencode:tom'; + const taskId = 'fb72209d-ea5b-45e0-9380-fe2e8235206e'; + const taskRef: TaskRef = { teamName, taskId, displayId: 'fb72209d' }; + await writeOpenCodeDeliveryFixture({ + baseDir: tmpDir, + teamName, + laneId, + records: [ + buildOpenCodeDeliveryRecord({ + teamName, + laneId, + taskRefs: [taskRef], + }), + ], + tasks: [ + { + id: taskId, + displayId: 'fb72209d', + subject: 'API docs', + owner: 'tom', + status: 'completed', + updatedAt: '2026-05-19T12:25:56.384Z', + comments: [ + { + id: 'other-member-comment', + author: 'alice', + text: 'I verified this task.', + createdAt: '2026-05-19T12:25:56.384Z', + type: 'regular', + }, + ], + historyEvents: [ + { + id: 'other-member-status', + type: 'status_changed', + from: 'in_progress', + to: 'completed', + actor: 'alice', + timestamp: '2026-05-19T12:25:56.384Z', + }, + ], + }, + ], + }); + + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(async () => []), + }); + const advisory = await service.getMemberAdvisory(teamName, 'tom'); + + expect(advisory).toMatchObject({ + kind: 'api_error', + reasonCode: 'backend_error', + }); + }); + + it('does not surface advisory for responded OpenCode records with committed visible proof', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-19T12:28:00.000Z')); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'relay-release'; + const laneId = 'secondary:opencode:tom'; + await writeOpenCodeDeliveryFixture({ + baseDir: tmpDir, + teamName, + laneId, + records: [ + buildOpenCodeDeliveryRecord({ + teamName, + laneId, + status: 'responded', + responseState: 'responded_visible_message', + inboxReadCommittedAt: '2026-05-19T12:27:04.858Z', + visibleReplyMessageId: 'visible-reply-1', + visibleReplyInbox: 'team-lead', + visibleReplyCorrelation: 'relayOfMessageId', + updatedAt: '2026-05-19T12:27:04.858Z', + }), + ], + }); + + const service = new TeamMemberRuntimeAdvisoryService({ + findMemberLogs: vi.fn(async () => []), + }); + const advisory = await service.getMemberAdvisory(teamName, 'tom'); + + expect(advisory).toBeNull(); + }); + it('suppresses stale OpenCode prompt delivery advisories after a visible runtime reply exists', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts index 2317c57c..bde8a0b4 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts @@ -186,6 +186,84 @@ describe('TeamTaskStallPolicy', () => { }); }); + it.each([ + ['turn_ended_after_touch', 4], + ['touch_then_other_turns', 5], + ['mid_turn_after_touch', 10], + ] as const)('uses the aggressive work threshold for %s', (signal, thresholdMinutes) => { + const task: TeamTask = { + id: 'task-work-threshold', + displayId: 'abcd4444', + subject: 'Work threshold', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], + }; + const turnEndRow = createExactRow({ + sourceOrder: 2, + messageUuid: 'msg-turn-end', + systemSubtype: 'turn_duration', + parsedMessage: createParsedMessage({ + uuid: 'msg-turn-end', + type: 'system', + }), + }); + const laterAssistantRow = createExactRow({ + sourceOrder: 3, + messageUuid: 'msg-later', + parsedMessage: createParsedMessage({ + uuid: 'msg-later', + type: 'assistant', + }), + }); + const postTouchRows = + signal === 'touch_then_other_turns' + ? [turnEndRow, laterAssistantRow] + : signal === 'mid_turn_after_touch' + ? [laterAssistantRow] + : [turnEndRow]; + const snapshot = createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + recordsByTaskId: new Map([[task.id, [createRecord()]]]), + exactRowsByFilePath: new Map([ + [ + '/tmp/session.jsonl', + [ + createExactRow({ + messageUuid: 'msg-touch', + toolUseIds: ['tool-1'], + }), + ...postTouchRows, + ], + ], + ]), + }); + const touchAtMs = Date.parse('2026-04-19T12:00:00.000Z'); + + expect( + policy.evaluateWork({ + now: new Date(touchAtMs + thresholdMinutes * 60_000 - 1), + task, + snapshot, + }) + ).toMatchObject({ + status: 'skip', + skipReason: 'below_threshold', + }); + expect( + policy.evaluateWork({ + now: new Date(touchAtMs + thresholdMinutes * 60_000), + task, + snapshot, + }) + ).toMatchObject({ + status: 'alert', + signal, + }); + }); + it('alerts OpenCode-owned tasks faster after weak start-only task comments', () => { const task: TeamTask = { id: 'task-open-weak', @@ -259,13 +337,26 @@ describe('TeamTaskStallPolicy', () => { ]), }); - const evaluation = policy.evaluateWork({ - now: new Date('2026-04-19T12:07:00.000Z'), - task, - snapshot, - }); + const touchAtMs = Date.parse('2026-04-19T12:00:00.000Z'); - expect(evaluation).toMatchObject({ + expect( + policy.evaluateWork({ + now: new Date(touchAtMs + 100_000 - 1), + task, + snapshot, + }) + ).toMatchObject({ + status: 'skip', + taskId: 'task-open-weak', + skipReason: 'below_threshold', + }); + expect( + policy.evaluateWork({ + now: new Date(touchAtMs + 100_000), + task, + snapshot, + }) + ).toMatchObject({ status: 'alert', taskId: 'task-open-weak', progressSignal: 'weak_start_only', @@ -273,7 +364,7 @@ describe('TeamTaskStallPolicy', () => { }); }); - it('keeps existing thresholds for weak comments from non-OpenCode owners', () => { + it('uses normal work thresholds for weak comments from non-OpenCode owners', () => { const task: TeamTask = { id: 'task-codex-weak', displayId: 'feed2222', @@ -347,7 +438,7 @@ describe('TeamTaskStallPolicy', () => { }); const evaluation = policy.evaluateWork({ - now: new Date('2026-04-19T12:07:00.000Z'), + now: new Date('2026-04-19T12:03:00.000Z'), task, snapshot, }); @@ -433,7 +524,7 @@ describe('TeamTaskStallPolicy', () => { }); const evaluation = policy.evaluateWork({ - now: new Date('2026-04-19T12:07:00.000Z'), + now: new Date('2026-04-19T12:03:00.000Z'), task, snapshot, }); @@ -751,6 +842,123 @@ describe('TeamTaskStallPolicy', () => { }); }); + it.each([ + ['turn_ended_after_touch', 5], + ['touch_then_other_turns', 6], + ['mid_turn_after_touch', 12], + ] as const)('uses the aggressive review threshold for %s', (signal, thresholdMinutes) => { + const task: TeamTask = { + id: 'task-review-threshold', + displayId: 'c0ffee55', + subject: 'Review threshold', + status: 'completed', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-review-started', + type: 'review_started', + timestamp: '2026-04-19T12:00:00.000Z', + from: 'review', + to: 'review', + actor: 'bob', + }, + ], + }; + const turnEndRow = createExactRow({ + filePath: '/tmp/review-threshold.jsonl', + sourceOrder: 2, + messageUuid: 'msg-review-threshold-end', + systemSubtype: 'turn_duration', + parsedMessage: createParsedMessage({ + uuid: 'msg-review-threshold-end', + type: 'system', + }), + }); + const laterAssistantRow = createExactRow({ + filePath: '/tmp/review-threshold.jsonl', + sourceOrder: 3, + messageUuid: 'msg-review-threshold-later', + parsedMessage: createParsedMessage({ + uuid: 'msg-review-threshold-later', + type: 'assistant', + }), + }); + const postTouchRows = + signal === 'touch_then_other_turns' + ? [turnEndRow, laterAssistantRow] + : signal === 'mid_turn_after_touch' + ? [laterAssistantRow] + : [turnEndRow]; + const record = createRecord({ + timestamp: '2026-04-19T12:00:00.000Z', + actor: { + memberName: 'bob', + role: 'member', + sessionId: 'session-b', + isSidechain: true, + }, + actorContext: { + relation: 'same_task', + activePhase: 'review', + }, + action: { + canonicalToolName: 'review_start', + category: 'review', + toolUseId: 'tool-review-threshold', + }, + source: { + messageUuid: 'msg-review-threshold', + filePath: '/tmp/review-threshold.jsonl', + toolUseId: 'tool-review-threshold', + sourceOrder: 1, + }, + }); + const snapshot = createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + reviewOpenTasks: [task], + resolvedReviewersByTaskId: new Map([ + [task.id, { reviewer: 'bob', source: 'history_review_started_actor' }], + ]), + recordsByTaskId: new Map([[task.id, [record]]]), + exactRowsByFilePath: new Map([ + [ + '/tmp/review-threshold.jsonl', + [ + createExactRow({ + filePath: '/tmp/review-threshold.jsonl', + messageUuid: 'msg-review-threshold', + toolUseIds: ['tool-review-threshold'], + }), + ...postTouchRows, + ], + ], + ]), + }); + const touchAtMs = Date.parse('2026-04-19T12:00:00.000Z'); + + expect( + policy.evaluateReview({ + now: new Date(touchAtMs + thresholdMinutes * 60_000 - 1), + task, + snapshot, + }) + ).toMatchObject({ + status: 'skip', + skipReason: 'below_threshold', + }); + expect( + policy.evaluateReview({ + now: new Date(touchAtMs + thresholdMinutes * 60_000), + task, + snapshot, + }) + ).toMatchObject({ + status: 'alert', + signal, + }); + }); + it('alerts for started-review stall when review_started actor is missing but same-task reviewer touch exists after the review start', () => { const task: TeamTask = { id: 'task-d', diff --git a/test/main/services/team/stallMonitor/featureGates.test.ts b/test/main/services/team/stallMonitor/featureGates.test.ts index 9ac0cf69..933b3fa7 100644 --- a/test/main/services/team/stallMonitor/featureGates.test.ts +++ b/test/main/services/team/stallMonitor/featureGates.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { - getTeamTaskStallActivationGraceMs, getOpenCodeWeakStartStallThresholdMs, + getTeamTaskStallActivationGraceMs, getTeamTaskStallScanIntervalMs, getTeamTaskStallStartupGraceMs, isOpenCodeTaskStallRemediationEnabled, @@ -21,10 +21,10 @@ describe('stallMonitor feature gates', () => { expect(isOpenCodeTaskStallRemediationEnabled()).toBe(true); expect(isTeamTaskStallScannerEnabled()).toBe(true); expect(isTeamTaskStallAlertsEnabled()).toBe(true); - expect(getTeamTaskStallScanIntervalMs()).toBe(60_000); + expect(getTeamTaskStallScanIntervalMs()).toBe(30_000); expect(getTeamTaskStallStartupGraceMs()).toBe(180_000); - expect(getTeamTaskStallActivationGraceMs()).toBe(120_000); - expect(getOpenCodeWeakStartStallThresholdMs()).toBe(120_000); + expect(getTeamTaskStallActivationGraceMs()).toBe(60_000); + expect(getOpenCodeWeakStartStallThresholdMs()).toBe(100_000); }); it('parses truthy and falsy environment values', () => { @@ -75,6 +75,6 @@ describe('stallMonitor feature gates', () => { expect(isOpenCodeTaskStallRemediationEnabled()).toBe(true); expect(isTeamTaskStallScannerEnabled()).toBe(true); expect(isTeamTaskStallAlertsEnabled()).toBe(true); - expect(getOpenCodeWeakStartStallThresholdMs()).toBe(120_000); + expect(getOpenCodeWeakStartStallThresholdMs()).toBe(100_000); }); }); diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index eba3443d..e52ad71b 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -1548,6 +1548,125 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); + it('allows selecting unauthenticated OpenCode when free models are available', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: false, + statusMessage: 'Provider not connected', + detailMessage: null, + capabilities: { teamLaunch: false }, + models: ['opencode/big-pickle'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onProviderChange = vi.fn(); + + const ControlledSelector = (): React.JSX.Element => { + const [provider, setProvider] = React.useState<'anthropic' | 'opencode'>('anthropic'); + return React.createElement(TeamModelSelector, { + providerId: provider, + onProviderChange: (nextProvider) => { + onProviderChange(nextProvider); + if (nextProvider === 'anthropic' || nextProvider === 'opencode') { + setProvider(nextProvider); + } + }, + value: '', + onValueChange: () => undefined, + }); + }; + + await act(async () => { + root.render(React.createElement(ControlledSelector)); + await Promise.resolve(); + }); + + const openCodeButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('OpenCode') + ); + expect(openCodeButton?.hasAttribute('disabled')).toBe(false); + expect(openCodeButton?.getAttribute('aria-disabled')).toBeNull(); + expect(openCodeButton?.textContent).not.toContain('Auth'); + + await act(async () => { + openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onProviderChange).toHaveBeenCalledWith('opencode'); + expect(host.textContent).toContain('OpenCode free models are available'); + expect(host.textContent).toContain('provider connection optional'); + expect(host.textContent).toContain( + 'You can use free OpenCode models such as Big Pickle without connecting a provider.' + ); + expect(host.textContent).not.toContain('OpenCode is not ready for team launch'); + expect(host.textContent).not.toContain('team launch available'); + expect(host.textContent).toContain('big-pickle'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps unauthenticated OpenCode selectable but does not promise free models when none are listed', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: false, + statusMessage: 'Provider not connected', + detailMessage: null, + capabilities: { teamLaunch: false }, + models: ['openai/gpt-5.4-mini'], + modelVerificationState: 'idle', + modelAvailability: [], + }, + ], + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'opencode', + onProviderChange: () => undefined, + value: '', + onValueChange: () => undefined, + }) + ); + await Promise.resolve(); + }); + + const openCodeButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('OpenCode') + ); + expect(openCodeButton?.hasAttribute('disabled')).toBe(false); + expect(host.textContent).toContain('OpenCode provider is not connected'); + expect(host.textContent).toContain('no free OpenCode model is listed yet'); + expect(host.textContent).toContain('provider-backed models need setup'); + expect(host.textContent).not.toContain('team launch available'); + expect(host.textContent).not.toContain('OpenCode free models are available'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('does not normalize the selected model while viewing OpenCode readiness diagnostics', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div');