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.
@@ -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;
- Agent Teams
+ Agent Teams AI
@@ -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');