-
+
-
- {t("settings.experimental.websearch.title")}
+
+ {t("settings.webTools.webSearch.title")}
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
index f86fe517b0..02fc76303d 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts
@@ -1203,7 +1203,13 @@ export const dict = {
"settings.section.configuration": "الإعدادات",
"settings.agentBehaviour.title": "سلوك الوكيل",
"settings.autoApprove.title": "الموافقة التلقائية",
- "settings.browser.title": "المتصفح",
+ "settings.webTools.title": "أدوات الويب",
+ "settings.webTools.description": "اضبط البحث على الويب وأتمتة المتصفح. تتصل طلبات البحث مباشرةً بـ Exa أو Parallel.",
+ "settings.webTools.webSearch.enable": "تمكين لجميع المزوّدين",
+ "settings.webTools.browserAutomation": "أتمتة المتصفح",
+ "settings.webTools.webSearch.title": "البحث على الويب",
+ "settings.webTools.webSearch.description":
+ "اجعل البحث على الويب متاحًا لنماذج جميع المزوّدين. تتصل عمليات البحث مباشرةً بـ Exa أو Parallel.",
"settings.checkpoints.title": "نقاط التحقق",
"settings.display.title": "العرض",
"settings.autocomplete.title": "الإكمال التلقائي",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts
index 35a1450ea1..902114dd85 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts
@@ -1224,7 +1224,14 @@ export const dict = {
"settings.section.configuration": "Configuração",
"settings.agentBehaviour.title": "Comportamento do Agente",
"settings.autoApprove.title": "Aprovação Automática",
- "settings.browser.title": "Navegador",
+ "settings.webTools.title": "Ferramentas da Web",
+ "settings.webTools.description":
+ "Configure a pesquisa na web e a automação do navegador. As solicitações de pesquisa se conectam diretamente ao Exa ou Parallel.",
+ "settings.webTools.webSearch.enable": "Ativar para todos os provedores",
+ "settings.webTools.browserAutomation": "Automação do navegador",
+ "settings.webTools.webSearch.title": "Pesquisa na Web",
+ "settings.webTools.webSearch.description":
+ "Disponibilize a pesquisa na web para modelos de todos os provedores. As pesquisas se conectam diretamente ao Exa ou Parallel.",
"settings.checkpoints.title": "Pontos de Verificação",
"settings.display.title": "Exibição",
"settings.autocomplete.title": "Autocompletar",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
index c19e73262c..dbb2e7b923 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts
@@ -1269,7 +1269,14 @@ export const dict = {
"settings.section.configuration": "Konfiguracija",
"settings.agentBehaviour.title": "Ponašanje agenta",
"settings.autoApprove.title": "Automatsko odobravanje",
- "settings.browser.title": "Preglednik",
+ "settings.webTools.title": "Web alati",
+ "settings.webTools.description":
+ "Konfigurišite web pretragu i automatizaciju preglednika. Zahtjevi za pretragu povezuju se direktno s Exa ili Parallel.",
+ "settings.webTools.webSearch.enable": "Omogući za sve pružaoce",
+ "settings.webTools.browserAutomation": "Automatizacija preglednika",
+ "settings.webTools.webSearch.title": "Web pretraga",
+ "settings.webTools.webSearch.description":
+ "Omogućite web pretragu modelima svih pružalaca. Pretrage se povezuju direktno s Exa ili Parallel.",
"settings.checkpoints.title": "Kontrolne tačke",
"settings.display.title": "Prikaz",
"settings.autocomplete.title": "Automatsko dovršavanje",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts
index 8e9a593dd3..028595f859 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts
@@ -1262,7 +1262,14 @@ export const dict = {
"settings.section.configuration": "Konfiguration",
"settings.agentBehaviour.title": "Agentadfærd",
"settings.autoApprove.title": "Automatisk godkendelse",
- "settings.browser.title": "Browser",
+ "settings.webTools.title": "Webværktøjer",
+ "settings.webTools.description":
+ "Konfigurer websøgning og browserautomatisering. Søgeanmodninger sendes direkte til Exa eller Parallel.",
+ "settings.webTools.webSearch.enable": "Aktivér for alle udbydere",
+ "settings.webTools.browserAutomation": "Browserautomatisering",
+ "settings.webTools.webSearch.title": "Websøgning",
+ "settings.webTools.webSearch.description":
+ "Gør websøgning tilgængelig for modeller fra alle udbydere. Søgninger sendes direkte til Exa eller Parallel.",
"settings.checkpoints.title": "Kontrolpunkter",
"settings.display.title": "Visning",
"settings.autocomplete.title": "Autofuldførelse",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts
index 380e7d3db4..4adcf7b72f 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts
@@ -1283,7 +1283,14 @@ export const dict = {
"settings.section.configuration": "Konfiguration",
"settings.agentBehaviour.title": "Agentenverhalten",
"settings.autoApprove.title": "Automatisch genehmigen",
- "settings.browser.title": "Browser",
+ "settings.webTools.title": "Web-Tools",
+ "settings.webTools.description":
+ "Konfigurieren Sie Websuche und Browserautomatisierung. Suchanfragen werden direkt an Exa oder Parallel gesendet.",
+ "settings.webTools.webSearch.enable": "Für alle Anbieter aktivieren",
+ "settings.webTools.browserAutomation": "Browserautomatisierung",
+ "settings.webTools.webSearch.title": "Websuche",
+ "settings.webTools.webSearch.description":
+ "Machen Sie die Websuche für Modelle aller Anbieter verfügbar. Suchanfragen werden direkt an Exa oder Parallel gesendet.",
"settings.checkpoints.title": "Prüfpunkte",
"settings.display.title": "Anzeige",
"settings.autocomplete.title": "Autovervollständigung",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts
index abe0350075..e926699acc 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts
@@ -1176,8 +1176,14 @@ export const dict = {
"settings.section.configuration": "Configuration",
"settings.agentBehaviour.title": "Agent Behaviour",
"settings.autoApprove.title": "Auto-Approve",
- "settings.browser.title": "Browser",
"settings.webTools.title": "Web Tools",
+ "settings.webTools.description":
+ "Configure web search and browser automation. Search requests connect directly to Exa or Parallel.",
+ "settings.webTools.webSearch.enable": "Enable for All Providers",
+ "settings.webTools.browserAutomation": "Browser Automation",
+ "settings.webTools.webSearch.title": "Web Search",
+ "settings.webTools.webSearch.description":
+ "Make web search available to models from all providers. Searches connect directly to Exa or Parallel.",
"settings.checkpoints.title": "Checkpoints",
"settings.display.title": "Display",
"settings.autocomplete.title": "Autocomplete",
@@ -1350,10 +1356,6 @@ export const dict = {
"settings.browser.description":
"When enabled, the AI agent can interact with web pages — navigating, clicking, typing, and taking screenshots. A Chrome window will open so you can watch the agent work.",
- "settings.webTools.description":
- "Configure web search and browser automation. Search requests connect directly to Exa or Parallel.",
- "settings.webTools.websearchEnable": "Enable for All Providers",
- "settings.webTools.browserAutomation": "Browser Automation",
"settings.browser.enable.title": "Enable Browser Automation",
"settings.browser.enable.description": "Register the Playwright MCP server with the CLI backend.",
"settings.browser.systemChrome.title": "Use System Chrome",
@@ -1414,9 +1416,6 @@ export const dict = {
"settings.experimental.lsp.description": "Enable language server protocol integration",
"settings.experimental.batch.title": "Batch Tool",
"settings.experimental.batch.description": "Enable batching of multiple tool calls",
- "settings.experimental.websearch.title": "Web Search",
- "settings.experimental.websearch.description":
- "Make web search available to models from all providers. Searches connect directly to Exa or Parallel.",
"settings.experimental.codebaseSearch.title": "Codebase Search",
"settings.experimental.codebaseSearch.description": "Enable AI-powered natural language search across your codebase",
"settings.experimental.imageGeneration.title": "Image Generation",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts
index e1cd429614..d66f4bd86f 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts
@@ -1276,7 +1276,14 @@ export const dict = {
"settings.section.configuration": "Configuración",
"settings.agentBehaviour.title": "Comportamiento del agente",
"settings.autoApprove.title": "Aprobación automática",
- "settings.browser.title": "Navegador",
+ "settings.webTools.title": "Herramientas web",
+ "settings.webTools.description":
+ "Configura la búsqueda web y la automatización del navegador. Las solicitudes de búsqueda se conectan directamente a Exa o Parallel.",
+ "settings.webTools.webSearch.enable": "Habilitar para todos los proveedores",
+ "settings.webTools.browserAutomation": "Automatización del navegador",
+ "settings.webTools.webSearch.title": "Búsqueda web",
+ "settings.webTools.webSearch.description":
+ "Permite que los modelos de todos los proveedores usen la búsqueda web. Las búsquedas se conectan directamente a Exa o Parallel.",
"settings.checkpoints.title": "Puntos de control",
"settings.display.title": "Pantalla",
"settings.autocomplete.title": "Autocompletado",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
index 71055c0d65..1ed7c73e8b 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts
@@ -1287,7 +1287,14 @@ export const dict = {
"settings.section.configuration": "Configuration",
"settings.agentBehaviour.title": "Comportement de l'agent",
"settings.autoApprove.title": "Approbation automatique",
- "settings.browser.title": "Navigateur",
+ "settings.webTools.title": "Outils web",
+ "settings.webTools.description":
+ "Configurez la recherche web et l’automatisation du navigateur. Les requêtes de recherche se connectent directement à Exa ou Parallel.",
+ "settings.webTools.webSearch.enable": "Activer pour tous les fournisseurs",
+ "settings.webTools.browserAutomation": "Automatisation du navigateur",
+ "settings.webTools.webSearch.title": "Recherche web",
+ "settings.webTools.webSearch.description":
+ "Rendez la recherche web disponible pour les modèles de tous les fournisseurs. Les recherches se connectent directement à Exa ou Parallel.",
"settings.checkpoints.title": "Points de contrôle",
"settings.display.title": "Affichage",
"settings.autocomplete.title": "Autocomplétion",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts
index 034276bb9c..b0c9a5f703 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts
@@ -1023,7 +1023,14 @@ export const dict = {
"settings.section.configuration": "Configurazione",
"settings.agentBehaviour.title": "Comportamento agente",
"settings.autoApprove.title": "Approvazione automatica",
- "settings.browser.title": "Browser",
+ "settings.webTools.title": "Strumenti web",
+ "settings.webTools.description":
+ "Configura la ricerca web e l'automazione del browser. Le richieste di ricerca si connettono direttamente a Exa o Parallel.",
+ "settings.webTools.webSearch.enable": "Abilita per tutti i provider",
+ "settings.webTools.browserAutomation": "Automazione del browser",
+ "settings.webTools.webSearch.title": "Ricerca web",
+ "settings.webTools.webSearch.description":
+ "Rendi disponibile la ricerca web ai modelli di tutti i provider. Le ricerche si connettono direttamente a Exa o Parallel.",
"settings.checkpoints.title": "Checkpoint",
"settings.display.title": "Visualizzazione",
"settings.autocomplete.title": "Autocompletamento",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
index 2c6bd6c9e3..fd60365ca9 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts
@@ -1257,7 +1257,14 @@ export const dict = {
"settings.section.configuration": "設定",
"settings.agentBehaviour.title": "エージェントの動作",
"settings.autoApprove.title": "自動承認",
- "settings.browser.title": "ブラウザ",
+ "settings.webTools.title": "ウェブツール",
+ "settings.webTools.description":
+ "ウェブ検索とブラウザ自動化を設定します。検索リクエストは Exa または Parallel に直接接続されます。",
+ "settings.webTools.webSearch.enable": "すべてのプロバイダーで有効化",
+ "settings.webTools.browserAutomation": "ブラウザ自動化",
+ "settings.webTools.webSearch.title": "ウェブ検索",
+ "settings.webTools.webSearch.description":
+ "すべてのプロバイダーのモデルでウェブ検索を利用できるようにします。検索は Exa または Parallel に直接接続されます。",
"settings.checkpoints.title": "チェックポイント",
"settings.display.title": "表示",
"settings.autocomplete.title": "オートコンプリート",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
index 990edeb5eb..9dd03144c2 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts
@@ -1210,7 +1210,14 @@ export const dict = {
"settings.section.configuration": "구성",
"settings.agentBehaviour.title": "에이전트 동작",
"settings.autoApprove.title": "자동 승인",
- "settings.browser.title": "브라우저",
+ "settings.webTools.title": "웹 도구",
+ "settings.webTools.description":
+ "웹 검색 및 브라우저 자동화를 구성합니다. 검색 요청은 Exa 또는 Parallel에 직접 연결됩니다.",
+ "settings.webTools.webSearch.enable": "모든 제공업체에 사용",
+ "settings.webTools.browserAutomation": "브라우저 자동화",
+ "settings.webTools.webSearch.title": "웹 검색",
+ "settings.webTools.webSearch.description":
+ "모든 제공업체의 모델에서 웹 검색을 사용할 수 있도록 합니다. 검색은 Exa 또는 Parallel에 직접 연결됩니다.",
"settings.checkpoints.title": "체크포인트",
"settings.display.title": "디스플레이",
"settings.autocomplete.title": "자동 완성",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
index 75d20e4f17..8158160380 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts
@@ -1218,7 +1218,14 @@ export const dict = {
"settings.section.configuration": "Configuratie",
"settings.agentBehaviour.title": "Agent Gedrag",
"settings.autoApprove.title": "Automatisch Goedkeuren",
- "settings.browser.title": "Browser",
+ "settings.webTools.title": "Webtools",
+ "settings.webTools.description":
+ "Configureer zoeken op internet en browserautomatisering. Zoekopdrachten maken rechtstreeks verbinding met Exa of Parallel.",
+ "settings.webTools.webSearch.enable": "Inschakelen voor alle providers",
+ "settings.webTools.browserAutomation": "Browserautomatisering",
+ "settings.webTools.webSearch.title": "Zoeken op internet",
+ "settings.webTools.webSearch.description":
+ "Maak zoeken op internet beschikbaar voor modellen van alle providers. Zoekopdrachten maken rechtstreeks verbinding met Exa of Parallel.",
"settings.checkpoints.title": "Controlepunten",
"settings.display.title": "Weergave",
"settings.autocomplete.title": "Automatisch Aanvullen",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts
index 68c4af800b..5e8b9faf1f 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts
@@ -1222,7 +1222,14 @@ export const dict = {
"settings.section.configuration": "Konfigurasjon",
"settings.agentBehaviour.title": "Agentoppførsel",
"settings.autoApprove.title": "Automatisk godkjenning",
- "settings.browser.title": "Nettleser",
+ "settings.webTools.title": "Nettverktøy",
+ "settings.webTools.description":
+ "Konfigurer nettsøk og nettleserautomatisering. Søk sendes direkte til Exa eller Parallel.",
+ "settings.webTools.webSearch.enable": "Aktiver for alle leverandører",
+ "settings.webTools.browserAutomation": "Nettleserautomatisering",
+ "settings.webTools.webSearch.title": "Nettsøk",
+ "settings.webTools.webSearch.description":
+ "Gjør nettsøk tilgjengelig for modeller fra alle leverandører. Søk sendes direkte til Exa eller Parallel.",
"settings.checkpoints.title": "Kontrollpunkter",
"settings.display.title": "Visning",
"settings.autocomplete.title": "Autofullfør",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
index f7238a444c..aa920ac5db 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts
@@ -1221,7 +1221,14 @@ export const dict = {
"settings.section.configuration": "Konfiguracja",
"settings.agentBehaviour.title": "Zachowanie agenta",
"settings.autoApprove.title": "Automatyczne zatwierdzanie",
- "settings.browser.title": "Przeglądarka",
+ "settings.webTools.title": "Narzędzia internetowe",
+ "settings.webTools.description":
+ "Skonfiguruj wyszukiwanie w sieci i automatyzację przeglądarki. Żądania wyszukiwania łączą się bezpośrednio z Exa lub Parallel.",
+ "settings.webTools.webSearch.enable": "Włącz dla wszystkich dostawców",
+ "settings.webTools.browserAutomation": "Automatyzacja przeglądarki",
+ "settings.webTools.webSearch.title": "Wyszukiwanie w sieci",
+ "settings.webTools.webSearch.description":
+ "Udostępnij wyszukiwanie w sieci modelom wszystkich dostawców. Wyszukiwania łączą się bezpośrednio z Exa lub Parallel.",
"settings.checkpoints.title": "Punkty kontrolne",
"settings.display.title": "Wyświetlanie",
"settings.autocomplete.title": "Autouzupełnianie",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
index 47a24933c3..68027826b8 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts
@@ -1268,7 +1268,14 @@ export const dict = {
"settings.section.configuration": "Конфигурация",
"settings.agentBehaviour.title": "Поведение агента",
"settings.autoApprove.title": "Автоодобрение",
- "settings.browser.title": "Браузер",
+ "settings.webTools.title": "Веб-инструменты",
+ "settings.webTools.description":
+ "Настройте веб-поиск и автоматизацию браузера. Поисковые запросы отправляются напрямую в Exa или Parallel.",
+ "settings.webTools.webSearch.enable": "Включить для всех провайдеров",
+ "settings.webTools.browserAutomation": "Автоматизация браузера",
+ "settings.webTools.webSearch.title": "Веб-поиск",
+ "settings.webTools.webSearch.description":
+ "Сделайте веб-поиск доступным для моделей всех провайдеров. Поисковые запросы отправляются напрямую в Exa или Parallel.",
"settings.checkpoints.title": "Контрольные точки",
"settings.display.title": "Отображение",
"settings.autocomplete.title": "Автодополнение",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts
index cac0eea7a9..c029e495b6 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts
@@ -1250,7 +1250,14 @@ export const dict = {
"settings.section.configuration": "การกำหนดค่า",
"settings.agentBehaviour.title": "พฤติกรรมของเอเจนต์",
"settings.autoApprove.title": "อนุมัติอัตโนมัติ",
- "settings.browser.title": "เบราว์เซอร์",
+ "settings.webTools.title": "เครื่องมือเว็บ",
+ "settings.webTools.description":
+ "กำหนดค่าการค้นหาเว็บและระบบอัตโนมัติของเบราว์เซอร์ คำขอค้นหาจะเชื่อมต่อโดยตรงกับ Exa หรือ Parallel",
+ "settings.webTools.webSearch.enable": "เปิดใช้สำหรับผู้ให้บริการทั้งหมด",
+ "settings.webTools.browserAutomation": "ระบบอัตโนมัติของเบราว์เซอร์",
+ "settings.webTools.webSearch.title": "ค้นหาเว็บ",
+ "settings.webTools.webSearch.description":
+ "ทำให้โมเดลจากผู้ให้บริการทั้งหมดใช้การค้นหาเว็บได้ การค้นหาจะเชื่อมต่อโดยตรงกับ Exa หรือ Parallel",
"settings.checkpoints.title": "จุดตรวจสอบ",
"settings.display.title": "การแสดงผล",
"settings.autocomplete.title": "เติมข้อความอัตโนมัติ",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
index e799d07141..a43c79880d 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts
@@ -1215,7 +1215,14 @@ export const dict = {
"settings.section.configuration": "Yapılandırma",
"settings.agentBehaviour.title": "Ajan Davranışı",
"settings.autoApprove.title": "Otomatik Onay",
- "settings.browser.title": "Tarayıcı",
+ "settings.webTools.title": "Web Araçları",
+ "settings.webTools.description":
+ "Web aramasını ve tarayıcı otomasyonunu yapılandırın. Arama istekleri doğrudan Exa veya Parallel'e bağlanır.",
+ "settings.webTools.webSearch.enable": "Tüm Sağlayıcılar İçin Etkinleştir",
+ "settings.webTools.browserAutomation": "Tarayıcı Otomasyonu",
+ "settings.webTools.webSearch.title": "Web Araması",
+ "settings.webTools.webSearch.description":
+ "Web aramasını tüm sağlayıcıların modelleri için kullanılabilir hale getirin. Aramalar doğrudan Exa veya Parallel'e bağlanır.",
"settings.checkpoints.title": "Kontrol Noktaları",
"settings.display.title": "Görünüm",
"settings.autocomplete.title": "Otomatik Tamamlama",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
index dfc15c248d..75783bc9e4 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts
@@ -1213,7 +1213,14 @@ export const dict = {
"settings.section.configuration": "Конфігурація",
"settings.agentBehaviour.title": "Поведінка агента",
"settings.autoApprove.title": "Автоматичне схвалення",
- "settings.browser.title": "Браузер",
+ "settings.webTools.title": "Вебінструменти",
+ "settings.webTools.description":
+ "Налаштуйте вебпошук і автоматизацію браузера. Пошукові запити надсилаються безпосередньо до Exa або Parallel.",
+ "settings.webTools.webSearch.enable": "Увімкнути для всіх постачальників",
+ "settings.webTools.browserAutomation": "Автоматизація браузера",
+ "settings.webTools.webSearch.title": "Вебпошук",
+ "settings.webTools.webSearch.description":
+ "Зробіть вебпошук доступним для моделей усіх постачальників. Пошукові запити надсилаються безпосередньо до Exa або Parallel.",
"settings.checkpoints.title": "Контрольні точки",
"settings.display.title": "Відображення",
"settings.autocomplete.title": "Автодоповнення",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
index 8432bbee90..9e1dd05729 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts
@@ -1229,7 +1229,12 @@ export const dict = {
"settings.section.configuration": "配置",
"settings.agentBehaviour.title": "智能体行为",
"settings.autoApprove.title": "自动审批",
- "settings.browser.title": "浏览器",
+ "settings.webTools.title": "网络工具",
+ "settings.webTools.description": "配置网页搜索和浏览器自动化。搜索请求会直接连接到 Exa 或 Parallel。",
+ "settings.webTools.webSearch.enable": "为所有提供商启用",
+ "settings.webTools.browserAutomation": "浏览器自动化",
+ "settings.webTools.webSearch.title": "网页搜索",
+ "settings.webTools.webSearch.description": "让所有提供商的模型都可使用网页搜索。搜索会直接连接到 Exa 或 Parallel。",
"settings.checkpoints.title": "检查点",
"settings.display.title": "显示",
"settings.autocomplete.title": "自动补全",
diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
index b69bb7054c..aed2a97179 100644
--- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
+++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts
@@ -1190,7 +1190,12 @@ export const dict = {
"settings.section.configuration": "設定",
"settings.agentBehaviour.title": "Agent 行為",
"settings.autoApprove.title": "自動核准",
- "settings.browser.title": "瀏覽器",
+ "settings.webTools.title": "網路工具",
+ "settings.webTools.description": "設定網頁搜尋和瀏覽器自動化。搜尋請求會直接連線至 Exa 或 Parallel。",
+ "settings.webTools.webSearch.enable": "為所有供應商啟用",
+ "settings.webTools.browserAutomation": "瀏覽器自動化",
+ "settings.webTools.webSearch.title": "網頁搜尋",
+ "settings.webTools.webSearch.description": "讓所有供應商的模型都可使用網頁搜尋。搜尋會直接連線至 Exa 或 Parallel。",
"settings.checkpoints.title": "檢查點",
"settings.display.title": "顯示",
"settings.autocomplete.title": "自動完成",
diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts
index 0dc2d05dc7..89dcc0af83 100644
--- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts
+++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts
@@ -39,7 +39,6 @@ export interface WatcherConfig {
export interface ExperimentalConfig {
batch_tool?: boolean
- websearch?: boolean
codebase_search?: boolean
image_generation?: boolean
image_generation_model?: string
@@ -155,6 +154,7 @@ export interface Config {
compaction?: CompactionConfig
commit_message?: CommitMessageConfig
tools?: Record
+ web_search?: boolean
auto_collapse_reasoning?: boolean
experimental?: ExperimentalConfig
sandbox?: SandboxConfig
diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts
index b0cd42f88c..ec806bf053 100644
--- a/packages/opencode/src/kilocode/config/overlay.ts
+++ b/packages/opencode/src/kilocode/config/overlay.ts
@@ -88,7 +88,7 @@ export namespace KilocodeConfigOverlay {
["disabled_providers"],
["watcher", "ignore"],
["instructions"],
- ["experimental", "websearch"],
+ ["web_search"],
["indexing", "enabled"],
["indexing", "provider"],
["indexing", "model"],
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index f8eb66469a..4df567dd6b 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -377,7 +377,7 @@ export const layer: Layer.Layer<
const filtered = (yield* all()).filter((tool) => {
if (!KiloToolRegistry.available(tool, input.agent)) return false // kilocode_change
if (tool.id === WebSearchTool.id) {
- if (cfg.experimental?.websearch === true) return true // kilocode_change
+ if (cfg.web_search === true) return true // kilocode_change
return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })
}
diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts
index 50529c043f..51902e1514 100644
--- a/packages/opencode/test/kilocode/config/config.test.ts
+++ b/packages/opencode/test/kilocode/config/config.test.ts
@@ -151,13 +151,15 @@ describe("global config updates", () => {
})
})
-describe("kilocode indexing config", () => {
+describe("kilocode web search config", () => {
test("accepts the websearch availability setting", () => {
- const config = Schema.decodeUnknownSync(Config.Info)({ experimental: { websearch: true } })
+ const config = Schema.decodeUnknownSync(Config.Info)({ web_search: true })
- expect(config.experimental?.websearch).toBe(true)
+ expect(config.web_search).toBe(true)
})
+})
+describe("kilocode indexing config", () => {
test("ignores retired semantic indexing flags in existing configs", async () => {
await using tmp = await tmpdir({ git: true })
await writeConfig(tmp.path, {
diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts
index e8a55f9ac7..a21deb1e5d 100644
--- a/packages/opencode/test/kilocode/server/config-overlay.test.ts
+++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts
@@ -204,17 +204,17 @@ describe("config overlay routes", () => {
test.serial("resolves and reverts project websearch overrides", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
- await setGlobal(global.path, { experimental: { websearch: true } })
+ await setGlobal(global.path, { web_search: true })
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
- body: JSON.stringify({ scope: "project", set: { experimental: { websearch: false } } }),
+ body: JSON.stringify({ scope: "project", set: { web_search: false } }),
}),
)
const overridden = await json(await req(project.path, "/config/overlay?scope=project"))
- expect(overridden.fields["experimental.websearch"]).toMatchObject({
+ expect(overridden.fields.web_search).toMatchObject({
source: "project",
inherited: false,
overridden: true,
@@ -225,11 +225,11 @@ describe("config overlay routes", () => {
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
- body: JSON.stringify({ scope: "project", unset: [["experimental", "websearch"]] }),
+ body: JSON.stringify({ scope: "project", unset: [["web_search"]] }),
}),
)
const inherited = await json(await req(project.path, "/config/overlay?scope=project"))
- expect(inherited.fields["experimental.websearch"]).toMatchObject({
+ expect(inherited.fields.web_search).toMatchObject({
source: "global",
inherited: true,
overridden: false,
diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts
index 393bea6f25..97e4e6c51c 100644
--- a/packages/opencode/test/tool/registry.test.ts
+++ b/packages/opencode/test/tool/registry.test.ts
@@ -52,12 +52,13 @@ const configLayer = TestConfig.layer({
type RegistryLayerOptions = {
flags?: Partial
plugin?: Layer.Layer
+ config?: Parameters[0] // kilocode_change
}
const registryLayer = (opts: RegistryLayerOptions = {}) =>
ToolRegistry.layer
.pipe(
- Layer.provide(configLayer),
+ Layer.provide(opts.config ? TestConfig.layer(opts.config) : configLayer), // kilocode_change
Layer.provide(opts.plugin ?? Plugin.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
@@ -118,6 +119,21 @@ const withBrokenPlugin = testEffect(
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
)
// kilocode_change start
+const websearch = testEffect(
+ Layer.mergeAll(
+ registryLayer({
+ config: {
+ get: () =>
+ Effect.succeed({
+ web_search: true,
+ provider: { openai: { options: { apiKey: "test-openai-key" } } },
+ }),
+ },
+ }),
+ node,
+ Agent.defaultLayer,
+ ),
+)
const sandboxed = testEffect(
Layer.mergeAll(registryLayer({ flags: { experimentalLspTool: true } }), node, Agent.defaultLayer),
)
@@ -139,6 +155,38 @@ function sandboxProfile(): Profile {
describe("tool.registry", () => {
// kilocode_change start
+ it.instance("hides websearch for a third-party provider by default", () =>
+ Effect.gen(function* () {
+ const registry = yield* ToolRegistry.Service
+ const agent = yield* Agent.Service
+ const build = yield* agent.get("build")
+ if (!build) return yield* Effect.die(new Error("build agent not found"))
+ const tools = yield* registry.tools({
+ providerID: ProviderV2.ID.openai,
+ modelID: ModelV2.ID.make("test"),
+ agent: build,
+ })
+
+ expect(tools.map((tool) => tool.id)).not.toContain("websearch")
+ }),
+ )
+
+ websearch.instance("shows websearch for a configured third-party provider when enabled", () =>
+ Effect.gen(function* () {
+ const registry = yield* ToolRegistry.Service
+ const agent = yield* Agent.Service
+ const build = yield* agent.get("build")
+ if (!build) return yield* Effect.die(new Error("build agent not found"))
+ const tools = yield* registry.tools({
+ providerID: ProviderV2.ID.openai,
+ modelID: ModelV2.ID.make("test"),
+ agent: build,
+ })
+
+ expect(tools.map((tool) => tool.id)).toContain("websearch")
+ }),
+ )
+
sandboxed.instance("preserves built-in network classification through production tool definition processing", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 0bd15820a7..5f9963def0 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1673,6 +1673,7 @@ export type Config = {
tools?: {
[key: string]: boolean
}
+ web_search?: boolean
attachment?: AttachmentConfig
enterprise?: {
url?: string
@@ -1699,7 +1700,6 @@ export type Config = {
disable_paste_summary?: boolean
batch_tool?: boolean
codebase_search?: boolean
- websearch?: boolean
image_generation?: boolean
image_generation_model?: string
agent_requirements?: boolean
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 7ea6d2387d..1451c3a6b3 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -26905,6 +26905,9 @@
"type": "boolean"
}
},
+ "web_search": {
+ "type": "boolean"
+ },
"attachment": {
"$ref": "#/components/schemas/AttachmentConfig"
},
@@ -27000,9 +27003,6 @@
"codebase_search": {
"type": "boolean"
},
- "websearch": {
- "type": "boolean"
- },
"image_generation": {
"type": "boolean"
},
From 25dcfb6cff23abe840f3a728ebe48deef6af9355 Mon Sep 17 00:00:00 2001
From: Kelly Sun
Date: Thu, 23 Jul 2026 14:28:32 -0400
Subject: [PATCH 003/140] docs: add Mixlayer provider page
Adds an AI-providers docs page for Mixlayer (OpenAI-compatible inference
for open models like GLM and Qwen) and a nav entry, documenting the
OpenAI-Compatible setup for both VS Code and the CLI.
---
packages/kilo-docs/lib/nav/ai-providers.ts | 1 +
.../kilo-docs/pages/ai-providers/mixlayer.md | 97 +++++++++++++++++++
2 files changed, 98 insertions(+)
create mode 100644 packages/kilo-docs/pages/ai-providers/mixlayer.md
diff --git a/packages/kilo-docs/lib/nav/ai-providers.ts b/packages/kilo-docs/lib/nav/ai-providers.ts
index f604826c7c..1a2dfe9ae3 100644
--- a/packages/kilo-docs/lib/nav/ai-providers.ts
+++ b/packages/kilo-docs/lib/nav/ai-providers.ts
@@ -46,6 +46,7 @@ export const AiProvidersNav: NavSection[] = [
{ href: "/ai-providers/groq", children: "Groq" },
{ href: "/ai-providers/cerebras", children: "Cerebras" },
{ href: "/ai-providers/fireworks", children: "Fireworks AI" },
+ { href: "/ai-providers/mixlayer", children: "Mixlayer" },
],
},
{
diff --git a/packages/kilo-docs/pages/ai-providers/mixlayer.md b/packages/kilo-docs/pages/ai-providers/mixlayer.md
new file mode 100644
index 0000000000..ac0221315a
--- /dev/null
+++ b/packages/kilo-docs/pages/ai-providers/mixlayer.md
@@ -0,0 +1,97 @@
+---
+title: "Using Mixlayer with Kilo Code | Fast Open-Model Inference"
+description: "Run open models like GLM and Qwen on Mixlayer's OpenAI-compatible API in Kilo Code. Setup guide for VS Code and the CLI."
+---
+
+# Using Mixlayer With Kilo Code
+
+Mixlayer is an inference platform for open models such as GLM and Qwen, with a serving stack built from scratch by core contributors to Candle. It exposes an OpenAI-compatible API, so you can use it in Kilo Code through the **OpenAI Compatible** provider.
+
+**Website:** [https://mixlayer.com/](https://mixlayer.com/)
+
+## Getting an API Key
+
+1. **Sign Up/Sign In:** Go to [Mixlayer](https://mixlayer.com/) and create an account or sign in.
+2. **Navigate to API Keys:** Open the [Mixlayer console](https://console.mixlayer.com/) and go to the API Keys page.
+3. **Create a Key:** Click **New Key**, give it a descriptive name (e.g., "Kilo Code"), and copy it. You will not be able to view it again.
+
+## Configuration in Kilo Code
+
+Mixlayer's API is OpenAI-compatible, with the base URL `https://models.mixlayer.ai/v1`. Configure it through Kilo Code's **OpenAI Compatible** provider.
+
+{% tabs %}
+{% tab label="VSCode" %}
+
+1. Open **Settings** (gear icon) and go to the **Providers** tab.
+2. Scroll to the bottom and click **Custom provider**.
+3. Fill in the dialog:
+ - **Provider ID** — `mixlayer`
+ - **Display name** — `Mixlayer`
+ - **Provider API** — **OpenAI Compatible**
+ - **Base URL** — `https://models.mixlayer.ai/v1`
+ - **API key** — your Mixlayer API key
+4. Kilo Code auto-fetches the available models from Mixlayer's `/v1/models` endpoint, so you can pick a model directly from the list. Click **Submit** to save.
+
+{% /tab %}
+{% tab label="CLI" %}
+
+Set the API key as an environment variable and define an OpenAI-compatible provider in your `kilo.json` config file (`~/.config/kilo/kilo.json` or `./kilo.json`):
+
+**Environment variable:**
+
+```bash
+export MIXLAYER_API_KEY="your-api-key"
+```
+
+**Config file:**
+
+```jsonc
+{
+ "provider": {
+ "mixlayer": {
+ "npm": "@ai-sdk/openai-compatible",
+ "env": ["MIXLAYER_API_KEY"],
+ "options": {
+ "baseURL": "https://models.mixlayer.ai/v1",
+ },
+ "models": {
+ "z-ai/glm-5.2": {
+ "name": "GLM-5.2",
+ "limit": { "context": 262144, "output": 262144 },
+ },
+ "qwen/qwen3.5-397b-a17b": {
+ "name": "Qwen3.5 397B A17B",
+ "limit": { "context": 131072, "output": 131072 },
+ },
+ },
+ },
+ },
+}
+```
+
+Then set your default model using the `provider-id/model-id` format:
+
+```jsonc
+{
+ "model": "mixlayer/z-ai/glm-5.2",
+}
+```
+
+{% /tab %}
+{% /tabs %}
+
+## Models
+
+Mixlayer serves open models including:
+
+- `z-ai/glm-5.2` — 256K context
+- `qwen/qwen3.5-397b-a17b` and the Qwen 3.5 / 3.6 line (vision-capable)
+- `moonshotai/kimi-k2.7-code`
+
+Tool calling and reasoning are supported across the model line. See the [Mixlayer docs](https://docs.mixlayer.com) for the full, current model list and supported parameters.
+
+## Tips and Notes
+
+- **Model list:** Kilo Code auto-detects available models from Mixlayer's `/v1/models` endpoint, so the picker stays current with your account.
+- **Pricing:** See the [Mixlayer console](https://console.mixlayer.com/) for current per-model pricing.
+- **Reasoning:** Qwen models support a thinking mode; reasoning tokens count against the output budget, so allow enough `limit.output` when reasoning is enabled.
From 46b7e55f8d8f1e4b0722076b09d4a56bf8031939 Mon Sep 17 00:00:00 2001
From: marius-kilocode
Date: Mon, 27 Jul 2026 16:44:57 +0200
Subject: [PATCH 004/140] feat(vscode): multi-project Agent Manager with
per-project state, sessions, and lifecycle
Add an experimental multi-project mode to the Agent Manager sidebar behind
kilo-code.new.experimental.multiProject (default off). A persistent project
registry catalogs additional git repositories across restarts, while the
workspace repository stays the pinned default project.
Extension:
- Immutable per-project contexts own all repository-bound services (state,
worktrees, setup scripts, stale tracking, pollers) with generation-based
invalidation and fail-closed trust checks.
- ProjectContexts manage activation, expansion, and fast switching; session
routes resolve directories exactly per project via a shared route service.
- pushProjectSessions caches each project's session list and re-posts it on
fresh skips; session.created/updated/deleted SSE events upsert into the
owning project's cache so externally created sessions appear immediately.
- Selection restore persists the active target per project and falls back to
the local context silently when the remembered target is gone.
- Worktree lifecycle handlers extracted into provider-lifecycle.ts with an
explicit deps object instead of ambient project scope.
- initializeState and onRequestState always refresh sessions: with zero
managed sessions the listing never ran and the sidebar skeletons forever.
- Log instead of dropping silently when a state-gated message is not ready.
Webview:
- ProjectList accordion with per-project sidebar body, search, actions, and
default-branch dialog; selecting a project header restores its target.
- Local session tabs and terminal contexts are bucketed per project so open
tabs never leak across projects sharing the LOCAL context.
- The active project's session list overlays the live session store so new
sessions show without waiting for a backend re-list.
- SectionHeader requires a DragDropProvider ancestor; the multi-project body
now provides one (its absence crashed the whole webview render).
- SidebarBody and TabBar extracted out of AgentManagerApp (3215 to 2748
lines); AgentManagerProvider down to 1887 with caps lowered accordingly.
Also includes the config write revision bindings and per-project indexing
consent groundwork that rode along on this branch.
---
.changeset/agent-manager-multi-project.md | 5 +
...ent-manager-multi-project-configuration.md | 262 ++++
...er-multi-project-implementation-handoff.md | 459 +++++++
.../agent-manager-multi-project-runtime.md | 351 +++++
...ent-manager-multi-project-shipping-gaps.md | 201 +++
.../agent-manager-multi-project-uniform-ui.md | 740 ++++++++++
packages/kilo-vscode/eslint.config.mjs | 15 +-
packages/kilo-vscode/package.json | 6 +
packages/kilo-vscode/src/KiloProvider.ts | 483 +++++--
.../src/agent-manager/AgentManagerProvider.ts | 620 ++++-----
.../src/agent-manager/GitStatsPoller.ts | 26 +-
.../src/agent-manager/PRStatusPoller.ts | 60 +-
.../src/agent-manager/WorktreeStateManager.ts | 24 +
.../kilo-vscode/src/agent-manager/host.ts | 40 +
.../src/agent-manager/pr-status-bridge.ts | 5 +
.../src/agent-manager/project-context.ts | 475 +++++++
.../src/agent-manager/project-init.ts | 237 ++++
.../src/agent-manager/project-messages.ts | 201 +++
.../src/agent-manager/project-paths.ts | 89 ++
.../src/agent-manager/project-pollers.ts | 178 +++
.../src/agent-manager/project-registry.ts | 177 +++
.../src/agent-manager/project-route.ts | 197 +++
.../src/agent-manager/project-scope.ts | 15 +
.../src/agent-manager/project-session-view.ts | 4 +
.../src/agent-manager/project-wiring.ts | 93 ++
.../src/agent-manager/provider-lifecycle.ts | 264 ++++
.../src/agent-manager/section-handler.ts | 7 +-
.../kilo-vscode/src/agent-manager/types.ts | 127 ++
.../src/agent-manager/vscode-host.ts | 82 ++
packages/kilo-vscode/src/extension.ts | 2 +
packages/kilo-vscode/src/indexing-consent.ts | 103 ++
.../src/kilo-provider/config-bindings.ts | 58 +
.../src/kilo-provider/early-message.ts | 15 +
.../src/kilo-provider/indexing-settings.ts | 12 +-
.../kilo-vscode/src/kilo-provider/options.ts | 23 +
.../tests/unit/agent-local-tabs-store.test.ts | 83 ++
.../tests/unit/agent-manager-arch.test.ts | 89 +-
.../tests/unit/agent-project-contexts.test.ts | 236 ++++
.../tests/unit/agent-project-messages.test.ts | 195 +++
.../tests/unit/agent-project-paths.test.ts | 91 ++
.../tests/unit/agent-project-pollers.test.ts | 175 +++
.../unit/agent-project-reactivate.test.ts | 54 +
.../tests/unit/agent-project-registry.test.ts | 199 +++
.../tests/unit/agent-project-restore.test.ts | 113 ++
.../tests/unit/agent-project-route.test.ts | 142 ++
.../unit/agent-project-selection.test.ts | 207 +++
.../tests/unit/agent-project-sessions.test.ts | 436 ++++++
.../tests/unit/config-bindings.test.ts | 58 +
.../tests/unit/config-scope.test.ts | 14 +-
.../tests/unit/git-stats-poller.test.ts | 99 ++
.../tests/unit/indexing-consent.test.ts | 47 +
.../kilo-provider-indexing-refresh.test.ts | 115 +-
.../kilo-provider-route-integration.test.ts | 327 +++++
.../kilo-vscode/tests/unit/navigate.test.ts | 404 +++++-
.../agent-manager/AgentManagerApp.tsx | 1216 ++++++-----------
.../agent-manager/NewWorktreeDialog.tsx | 11 +-
.../agent-manager/ProjectActions.tsx | 96 ++
.../agent-manager/ProjectBranchDialog.tsx | 73 +
.../webview-ui/agent-manager/ProjectList.tsx | 213 +++
.../agent-manager/ProjectSidebarBody.tsx | 258 ++++
.../agent-manager/ProjectsSection.tsx | 102 ++
.../webview-ui/agent-manager/SidebarBody.tsx | 462 +++++++
.../webview-ui/agent-manager/TabBar.tsx | 269 ++++
.../UnassignedSessionsSection.tsx | 3 +-
.../webview-ui/agent-manager/WorktreeItem.tsx | 4 +-
.../agent-manager/agent-manager.css | 149 ++
.../webview-ui/agent-manager/i18n/ar.ts | 5 +
.../webview-ui/agent-manager/i18n/br.ts | 5 +
.../webview-ui/agent-manager/i18n/bs.ts | 5 +
.../webview-ui/agent-manager/i18n/da.ts | 5 +
.../webview-ui/agent-manager/i18n/de.ts | 5 +
.../webview-ui/agent-manager/i18n/en.ts | 5 +
.../webview-ui/agent-manager/i18n/es.ts | 5 +
.../webview-ui/agent-manager/i18n/fr.ts | 5 +
.../webview-ui/agent-manager/i18n/it.ts | 5 +
.../webview-ui/agent-manager/i18n/ja.ts | 5 +
.../webview-ui/agent-manager/i18n/ko.ts | 5 +
.../webview-ui/agent-manager/i18n/nl.ts | 5 +
.../webview-ui/agent-manager/i18n/no.ts | 5 +
.../webview-ui/agent-manager/i18n/pl.ts | 5 +
.../webview-ui/agent-manager/i18n/ru.ts | 5 +
.../webview-ui/agent-manager/i18n/th.ts | 5 +
.../webview-ui/agent-manager/i18n/tr.ts | 5 +
.../webview-ui/agent-manager/i18n/uk.ts | 5 +
.../webview-ui/agent-manager/i18n/zh.ts | 5 +
.../webview-ui/agent-manager/i18n/zht.ts | 5 +
.../agent-manager/local-tabs-store.ts | 85 ++
.../webview-ui/agent-manager/navigate.ts | 93 ++
.../webview-ui/agent-manager/project-live.ts | 74 +
.../webview-ui/agent-manager/project-nav.ts | 146 ++
.../agent-manager/project-restore.ts | 60 +
.../agent-manager/project-selection.ts | 26 +
.../agent-manager/project-sessions-live.ts | 43 +
.../agent-manager/selection-actions.ts | 75 +
.../agent-manager/sidebar-search.ts | 1 +
.../components/settings/ExperimentalTab.tsx | 15 +-
.../src/components/settings/IndexingTab.tsx | 101 +-
.../webview-ui/src/context/config.tsx | 150 +-
.../webview-ui/src/context/indexing.tsx | 9 +-
.../kilo-vscode/webview-ui/src/i18n/ar.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/br.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/bs.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/da.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/de.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/en.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/es.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/fr.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/it.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/ja.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/ko.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/nl.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/no.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/pl.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/ru.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/th.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/tr.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/uk.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/zh.ts | 3 +
.../kilo-vscode/webview-ui/src/i18n/zht.ts | 3 +
.../webview-ui/src/stories/StoryProviders.tsx | 3 +
.../src/stories/agent-manager.stories.tsx | 149 ++
.../src/types/messages/extension-messages.ts | 79 ++
.../webview-ui/src/types/messages/sessions.ts | 4 +
.../src/types/messages/webview-messages.ts | 96 ++
.../webview-ui/src/utils/config-scope.ts | 22 -
packages/opencode/src/kilocode/bootstrap.ts | 19 +-
.../opencode/src/kilocode/config/overlay.ts | 82 +-
.../opencode/src/kilocode/config/writer.ts | 104 ++
packages/opencode/src/kilocode/indexing.ts | 40 +-
.../server/httpapi/groups/config-console.ts | 30 +-
.../server/httpapi/groups/indexing.ts | 17 +
.../server/httpapi/handlers/config-console.ts | 87 +-
.../server/httpapi/handlers/indexing.ts | 10 +-
.../src/kilocode/server/httpapi/server.ts | 2 +
.../server/routes/instance/httpapi/server.ts | 14 +-
.../test/kilocode/indexing-startup.test.ts | 105 +-
.../kilocode/server/config-overlay.test.ts | 288 +++-
.../kilocode/tool-registry-indexing.test.ts | 4 +
packages/sdk/js/src/v2/gen/sdk.gen.ts | 44 +
packages/sdk/js/src/v2/gen/types.gen.ts | 92 +-
packages/sdk/openapi.json | 501 +++++--
141 files changed, 12752 insertions(+), 1674 deletions(-)
create mode 100644 .changeset/agent-manager-multi-project.md
create mode 100644 .kilo/plans/agent-manager-multi-project-configuration.md
create mode 100644 .kilo/plans/agent-manager-multi-project-implementation-handoff.md
create mode 100644 .kilo/plans/agent-manager-multi-project-runtime.md
create mode 100644 .kilo/plans/agent-manager-multi-project-shipping-gaps.md
create mode 100644 .kilo/plans/agent-manager-multi-project-uniform-ui.md
create mode 100644 packages/kilo-vscode/src/agent-manager/project-context.ts
create mode 100644 packages/kilo-vscode/src/agent-manager/project-init.ts
create mode 100644 packages/kilo-vscode/src/agent-manager/project-messages.ts
create mode 100644 packages/kilo-vscode/src/agent-manager/project-paths.ts
create mode 100644 packages/kilo-vscode/src/agent-manager/project-pollers.ts
create mode 100644 packages/kilo-vscode/src/agent-manager/project-registry.ts
create mode 100644 packages/kilo-vscode/src/agent-manager/project-route.ts
create mode 100644 packages/kilo-vscode/src/agent-manager/project-scope.ts
create mode 100644 packages/kilo-vscode/src/agent-manager/project-session-view.ts
create mode 100644 packages/kilo-vscode/src/agent-manager/project-wiring.ts
create mode 100644 packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts
create mode 100644 packages/kilo-vscode/src/indexing-consent.ts
create mode 100644 packages/kilo-vscode/src/kilo-provider/config-bindings.ts
create mode 100644 packages/kilo-vscode/tests/unit/agent-local-tabs-store.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/agent-project-messages.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/agent-project-paths.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/agent-project-pollers.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/agent-project-reactivate.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/agent-project-registry.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/agent-project-restore.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/agent-project-route.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/agent-project-selection.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/agent-project-sessions.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/config-bindings.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/indexing-consent.test.ts
create mode 100644 packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/ProjectActions.tsx
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/ProjectBranchDialog.tsx
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/local-tabs-store.ts
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/project-live.ts
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/project-nav.ts
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/project-restore.ts
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/project-selection.ts
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/project-sessions-live.ts
create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/selection-actions.ts
create mode 100644 packages/opencode/src/kilocode/config/writer.ts
diff --git a/.changeset/agent-manager-multi-project.md b/.changeset/agent-manager-multi-project.md
new file mode 100644
index 0000000000..1212a52f99
--- /dev/null
+++ b/.changeset/agent-manager-multi-project.md
@@ -0,0 +1,5 @@
+---
+"kilo-code": minor
+---
+
+Add an experimental multi-project foundation for Agent Manager behind the Multi-Project Agent Manager toggle in Kilo Settings > Experimental. A persistent project registry catalogs additional Git repositories across restarts while the current workspace repository stays the pinned default project, and all repository-bound services (state, worktrees, setup scripts, stale tracking, polling) are now owned by immutable per-project contexts that can be activated, expanded, and switched through new project protocol messages. Every expanded project runs its own git stats and PR pollers, so all visible project accordions show live data at the same time; switching projects keeps previously visited projects expanded and restores the exact worktree or session that was open there. Sessions created anywhere (sidebar, another window, the CLI) appear in the owning project's list immediately, and open tabs stay private to the project they were opened in. Single-project behavior is unchanged when the experiment is disabled.
diff --git a/.kilo/plans/agent-manager-multi-project-configuration.md b/.kilo/plans/agent-manager-multi-project-configuration.md
new file mode 100644
index 0000000000..340082fbb6
--- /dev/null
+++ b/.kilo/plans/agent-manager-multi-project-configuration.md
@@ -0,0 +1,262 @@
+# Agent Manager multi-project configuration architecture
+
+**Status:** Blocking architecture for multi-project release
+
+**Date:** 2026-07-22
+
+This document is the canonical configuration specification for Agent Manager multi-project support. The main UI/runtime plan references it and must not duplicate or contradict it.
+
+The executable sequence from the current branch is [`agent-manager-multi-project-implementation-handoff.md`](./agent-manager-multi-project-implementation-handoff.md).
+
+## Decision
+
+Keep the useful current split between user and project settings, but make every Settings read and write target explicit, immutable, revisioned, and independent from Agent Manager activation.
+
+Multi-project must not ship broadly while Settings can load a draft for project A and resolve its save target from the mutable active project B.
+
+## Current behavior
+
+Kilo has four configuration stores:
+
+| Store | Example | Owner |
+|---|---|---|
+| VS Code preferences | VS Code `settings.json` | This VS Code user/installation |
+| Kilo user config | `~/.config/kilo/kilo.json` | User defaults across projects and Kilo clients |
+| Kilo project config | `/.kilo/kilo.jsonc` | Repository behavior and overrides |
+| Runtime/session state | In-memory, directory-qualified | One project, worktree, or session |
+
+The shared Settings save currently calls `splitConfigByScope()`:
+
+- `commit_message` is written to project config;
+- `indexing.enabled` is written to project config;
+- all other generic Settings fields are written to user config.
+
+The Indexing tab also has an explicit Global/Project selector and can write the entire `indexing` object to either layer. This is too broad because project config can receive provider/model/vector-store credentials and infrastructure settings.
+
+Several controls are VS Code preferences and bypass Kilo config entirely, including autocomplete UI, browser automation, notifications, max auto-approve cost, commit-message output language, and indexing button visibility.
+
+## Confirmed blocking failure
+
+The current protocol does not bind a draft to the config target it was loaded from:
+
+1. `KiloProvider.fetchAndSendConfig()` resolves a mutable current directory and sends unqualified `configLoaded` state.
+2. The webview owns one global/project/effective draft.
+3. Agent Manager changes the active project from A to B.
+4. The webview sends an unqualified `updateConfig`.
+5. `KiloProvider.handleUpdateConfig()` resolves the current directory again at save time and may write A's draft to B.
+
+Reads are directory-scoped, but writes are not bound to the read target. This is the release blocker.
+
+The backend also lacks an expected target/revision precondition, so external editors or another window can overwrite a config between read and save.
+
+## Ownership policy
+
+### VS Code preferences
+
+These remain in VS Code settings and do not participate in project config:
+
+- extension language and presentation preferences;
+- autocomplete enablement, keybindings, provider, and model;
+- browser automation enablement/system Chrome/headless mode;
+- notification enablement and sound;
+- maximum automatic approval cost;
+- commit-message output language;
+- indexing button visibility while indexing is disabled;
+- multi-project feature enablement.
+
+### Kilo user config
+
+These are personal defaults or security policy and should be edited in User scope:
+
+- default, small, and subagent models and variants;
+- provider enablement, custom providers, credentials, and endpoints;
+- user/global agents and default agent;
+- permission defaults and user tool defaults;
+- sandbox policy, network access, writable paths, and allowed hosts;
+- compaction, checkpoint/snapshot, and tool-output defaults;
+- username and display behavior;
+- sharing, remote control, telemetry, and experimental features;
+- user/global formatter, LSP, MCP, skills, instructions, commands, and workflows;
+- indexing provider, model, credentials, vector storage, and global tuning defaults.
+
+A trusted project may override many of these at runtime, but editing User scope never writes those overrides.
+
+### Kilo project config
+
+These describe repository behavior and are valid project settings:
+
+- commit-message prompt;
+- repository indexing file extensions, include/ignore rules, and deliberate project tuning overrides;
+- repository instructions;
+- repository skill paths;
+- repository commands/workflows;
+- trusted project agents;
+- trusted project MCP servers;
+- repository formatter/LSP overrides;
+- repository watcher ignores;
+- repository-specific tool restrictions and permission requests.
+
+Project configuration may override user model/agent/tool defaults, but provider credentials and security-policy weakening must not be silently authored into a repository.
+
+### Machine-local project consent
+
+Indexing enablement is privacy consent, not repository configuration. Store it outside the repository, keyed by canonical `ProjectId` in machine-local extension state:
+
+- newly observed projects default to indexing disabled;
+- users explicitly enable indexing for one project on this machine;
+- repository config cannot enable indexing;
+- repository config may describe what to index, but consent gates whether indexing starts;
+- canonical project identity prevents a symlink or alternate path from bypassing consent.
+
+Effective indexing requires both valid user-global indexing configuration and machine-local consent for that project.
+
+### Current tabs
+
+| Settings tab | Correct editable target |
+|---|---|
+| Models | User by default; explicit Project scope may override models/agents |
+| Providers | User only for credentials/endpoints; project-provided entries are source-labelled |
+| Agent Behaviour | User or explicit trusted Project scope |
+| Auto Approve | User Kilo config; max cost remains VS Code preference |
+| Browser | VS Code preferences |
+| Checkpoints | User default or explicit Project override |
+| Display | User config |
+| Autocomplete | VS Code preferences |
+| Notifications | VS Code preferences |
+| Context | User defaults; repository watcher/instruction rules in explicit Project scope |
+| Commit Message | Project scope for prompt; language remains VS Code preference |
+| Indexing | User for provider/model/credentials/storage; machine-local project consent for enablement; Project for repository rules |
+| Experimental | User config; multi-project also mirrors to VS Code preference |
+| Sandboxing | User config only |
+| Language | VS Code preference |
+| MCP/Commands/Skills | User defaults or explicit trusted Project scope |
+
+No field silently chooses a file during save. The UI must display its scope.
+
+## Settings UX
+
+Use explicit scope and project controls:
+
+```text
+Scope: User | Project
+
+Project: backend
+Target: /projects/backend/.kilo/kilo.jsonc
+```
+
+- User scope always targets user config.
+- Project scope requires an explicit trusted project selector.
+- The Settings selector is separate from Agent Manager's active project.
+- Opening Settings may initialize the project selector once from the current project, but later Agent Manager switches never change it.
+- A dirty project draft cannot move to another project. Selector changes require Save, Discard, or Stay.
+- Inherited values show source badges such as User, Project: backend, and Managed.
+- Project scope offers Override and Reset to inherited.
+- Project-sourced providers cannot be silently deleted from project config through User scope.
+
+Runtime config still follows the exact session directory. Settings Project scope targets the registered project root, not the active worktree. Worktree-config editing is a separate future feature requiring an explicit `WorktreeRef`.
+
+## Immutable binding contract
+
+A Settings read returns an opaque binding:
+
+```ts
+interface SettingsBinding {
+ id: string
+ connectionGeneration: number
+ scope: "global" | "project"
+ project?: {
+ projectId: string
+ root: string
+ generation: number
+ }
+ directory: string
+ target: {
+ scope: "global" | "project"
+ path: string
+ revision: string
+ exists: boolean
+ writable: boolean
+ }
+}
+```
+
+The write contains only the opaque binding and patch:
+
+```ts
+interface WriteSettingsConfig {
+ type: "settingsConfig.write"
+ requestId: string
+ bindingId: string
+ set: Record
+ unset: string[][]
+}
+```
+
+The extension stores the authoritative binding. On write it must:
+
+1. reject unknown/expired bindings;
+2. verify project existence, generation, and trust;
+3. capture the binding before the first await;
+4. use the binding's stored directory and scope;
+5. never call `getWorkspaceDirectory()`, `contexts.active()`, or use a worktree/session fallback;
+6. clear a draft only from the matching `{ requestId, bindingId }` response.
+
+Bindings expire after save, reconnect, trust revocation, project removal, or context generation change.
+
+## Backend revision contract
+
+`GET /config/overlay` must return the exact global/project target path, parsed raw target config, effective config/source metadata, and a revision.
+
+The revision is a SHA-256 fingerprint of canonical target path, existence marker, and exact file bytes. This catches content changes, JSONC comment-only edits, and target changes.
+
+`PATCH /config/overlay` accepts one scope and requires:
+
+```ts
+{
+ scope: "global" | "project"
+ set: Record
+ unset: string[][]
+ expected: {
+ path: string
+ revision: string
+ }
+}
+```
+
+The backend must re-resolve the authoritative target, verify path/revision under a target lock, patch the raw target layer, validate it, atomically replace the file, and return a fresh snapshot. It never accepts an arbitrary client path.
+
+Expected failures include expired binding, unknown/untrusted project, changed target, revision conflict, invalid config, non-writable target, and I/O failure. Every failure preserves the draft.
+
+## Required implementation
+
+1. Add revisioned target descriptors and compare-and-swap writes to the Kilo config overlay API.
+2. Split activation-bound runtime config state from binding-keyed Settings editor state.
+3. Replace unqualified `configLoaded`/`updateConfig` with settings read/write messages carrying request and binding IDs.
+4. Replace hidden `splitConfigByScope` saves with explicit scope on every editable control.
+5. Restrict Indexing Project scope to repository rules; keep provider/model/credentials/storage in User scope.
+6. Move indexing enablement from project config to machine-local consent keyed by canonical `ProjectId`, default off.
+7. Audit direct config mutators outside the save bar, especially provider disconnect, imports/resets, custom providers, work styles, permission rules, and indexing actions.
+8. Make Open Project Config take a `ProjectRef`, resolve the immutable registered root, and verify trust.
+9. Partition config caches/events by scope, directory, target, revision, and activation generation.
+
+## Blocking tests
+
+- Load Settings for A, switch Agent Manager to B, save: only A's bound target changes.
+- Same test while selecting A/B worktrees and sessions.
+- User-scope save always changes only user config.
+- Project-scope save requires the explicit trusted project and changes only its registered root config.
+- Dirty drafts survive Agent Manager switches and cannot migrate between Settings projects.
+- Out-of-order reads and writes update only the matching request/binding.
+- External file change causes a revision conflict without losing the draft.
+- A changed config target path causes a target conflict.
+- Project removal, generation change, or trust revocation expires its binding.
+- Indexing provider/model/credentials/storage never enter project config through the form.
+- A repository file containing `indexing.enabled: true` cannot grant indexing consent.
+- New projects default to indexing disabled until explicitly enabled on this machine.
+- Consent follows canonical project identity across symlink/path aliases and never leaks to another project.
+- `commit_message.prompt` and repository indexing rules still support explicit project writes.
+- Runtime worktree config uses the worktree directory while Project Settings remains bound to the registered project root.
+
+## Release gate
+
+Keep multi-project disabled by default until the immutable binding/revision contract and the blocking tests above are implemented. The useful existing project-local behavior should be preserved, not removed; its write target must become explicit and immutable.
diff --git a/.kilo/plans/agent-manager-multi-project-implementation-handoff.md b/.kilo/plans/agent-manager-multi-project-implementation-handoff.md
new file mode 100644
index 0000000000..510a5e1c38
--- /dev/null
+++ b/.kilo/plans/agent-manager-multi-project-implementation-handoff.md
@@ -0,0 +1,459 @@
+# Agent Manager multi-project implementation handoff
+
+**Status:** Implementation-ready handoff from the current branch
+
+**Date:** 2026-07-22
+
+This document tells the next implementation model exactly how to continue from branch `abalone-bactrosaurus` at commit `8f48da2278` plus the uncommitted plan files. It is the execution checklist. Detailed decisions live in:
+
+- [`agent-manager-multi-project-configuration.md`](./agent-manager-multi-project-configuration.md) for configuration ownership, bindings, revisions, indexing consent, and settings tests;
+- [`agent-manager-multi-project-uniform-ui.md`](./agent-manager-multi-project-uniform-ui.md) for runtime, routing, lifecycle, uniform sidebar UI, and acceptance criteria.
+
+If this handoff conflicts with either architecture document, stop and resolve the contradiction before coding.
+
+## Goal
+
+Finish the current prototype into a safe, complete multi-project Agent Manager behind a default-off VS Code experimental flag.
+
+The feature is done only when:
+
+- multiple expanded projects show the same full Agent Manager sidebar behavior;
+- every action uses explicit project/worktree/session ownership;
+- Settings cannot write the wrong project's config;
+- indexing requires machine-local consent per canonical project;
+- project identity is canonical, Git-root-aware, common-dir-safe, and remote-authority-aware;
+- single-project behavior remains unchanged when the flag is off.
+
+## Current branch baseline
+
+### Implemented and worth keeping
+
+- persistent additional-project registry with serialized/re-read mutations;
+- separate `ProjectContext` objects and per-project Agent Manager state files;
+- project-stamped state, stats, PR, and session payloads;
+- canonical worktree presence comparison for symlink/`/tmp` aliases;
+- per-project session listing from project root and worktree directories;
+- partial production `ProjectRouteService` integration and raw-session ambiguity rejection;
+- project-qualified sidebar DOM IDs and cross-project previous/next navigation;
+- atomic selection validation and same-project activation fast path;
+- flag-off return to pinned project;
+- Experimental Settings toggle and translations;
+- focused unit tests for registry, contexts, paths, pollers, routing, sessions, selection, and navigation.
+
+### Not implemented or incomplete
+
+- immutable/revisioned Settings bindings;
+- machine-local indexing consent;
+- canonical pinned Git toplevel, `commonGitDir`, URI scheme/authority identity;
+- strict project envelopes for all Agent Manager operations;
+- complete route-service use for every session/terminal/diff/share/permission operation;
+- one full sidebar body shared by single- and multi-project modes;
+- uniform per-context poller and stale-state ownership;
+- all repository mutations through `ProjectContext.run()`;
+- complete real two-project E2E verification.
+
+### Known diff cleanup
+
+- remove unrelated `packages/opencode/package.json` ordering/dependency drift;
+- restore unrelated `bun.lock` ordering/version drift unless required by a retained change;
+- remove `experimental.multi_project` from the CLI config schema and generated SDK once the VS Code flag is the sole source of truth;
+- rewrite the changeset after final behavior is complete.
+
+Do not reset or discard unrelated user changes. Inspect every cleanup diff before applying it.
+
+## Non-negotiable invariants
+
+1. Agent Manager activation is not a Settings write target.
+2. No identified operation falls back to the active project or workspace root in multi-project mode.
+3. A project config write targets the explicitly selected registered project root, never an active worktree.
+4. Runtime config remains directory-correct: project Local uses root, worktree sessions use exact worktree directory.
+5. Repository config cannot grant indexing consent.
+6. New projects default to indexing disabled on this machine.
+7. Provider/API credentials and indexing infrastructure never enter project config through Settings.
+8. Raw session/worktree IDs are never sufficient UI/runtime identity when multiple projects are exposed.
+9. The full existing sidebar behavior is reused; do not maintain a simplified multi-project copy.
+10. Do not raise `AgentManagerProvider.ts` or `AgentManagerApp.tsx` line caps.
+
+## Work order
+
+Complete slices in order. Do not start the next slice while the current slice's tests or checks fail.
+
+## Slice 0A: consolidate the feature flag
+
+### Required result
+
+Use one source of truth: VS Code application setting `kilo-code.new.experimental.multiProject`.
+
+### Required changes
+
+1. Remove `experimental.multi_project` from:
+ - `packages/core/src/v1/config/config.ts`;
+ - `packages/sdk/js/src/v2/gen/types.gen.ts` by regenerating the SDK after schema removal;
+ - `packages/kilo-vscode/webview-ui/src/types/messages/config.ts`.
+2. Do not hand-edit generated SDK files. Run `./script/generate.ts` from repository root after endpoint/schema changes.
+3. Change `ExperimentalTab.tsx` so the toggle reads the VS Code setting delivered in `configLoaded.settings`, not `config().experimental`.
+4. Add `multiProject` to the extension settings payload sent by `KiloProvider.fetchAndSendConfig()`.
+5. Toggling it sends only `updateSetting("experimental.multiProject", checked)`.
+6. Keep `VscodeHost.multiProject()` and its change listener reading the same VS Code setting.
+
+### Tests
+
+- toggle initial state reflects VS Code setting when CLI config disagrees;
+- toggling updates the VS Code setting and Agent Manager reacts;
+- no CLI/project config file gains `experimental.multi_project`.
+
+### Stop gate
+
+Run from `packages/kilo-vscode/`:
+
+```sh
+bun run format
+bun run format:check
+bun run typecheck
+bun run lint
+bun run test:unit
+```
+
+Do not continue if any command fails.
+
+## Slice 0B: machine-local indexing consent
+
+### Required result
+
+Indexing enablement is explicit machine-local consent keyed by canonical `ProjectId`, default false. Project config controls indexing rules but cannot enable indexing.
+
+### Required changes
+
+1. Add a versioned machine-local consent store in the extension, not repository config and not synced across machines.
+2. Use canonical project identity as the key. The pinned project and registered projects use the same identity resolver.
+3. Remove hidden routing of `indexing.enabled` to project config from `webview-ui/src/utils/config-scope.ts`.
+4. Remove project/global inheritance logic for `indexing.enabled` from `indexing-tab-state.ts`.
+5. The Indexing UI requires an explicit project selector for enablement and reads/writes consent through dedicated messages.
+6. Keep these in User config only:
+ - provider;
+ - model and dimension;
+ - credentials/API keys/base URLs;
+ - vector-store type and connection/storage settings;
+ - machine/infrastructure tuning defaults.
+7. Keep these available in explicit Project scope:
+ - file extensions;
+ - repository include/ignore rules;
+ - deliberate repository chunking/tuning overrides.
+8. Indexing startup/status must require both valid User indexing configuration and consent for the routed project.
+9. A repository containing `indexing.enabled: true` must not enable indexing.
+
+### Suggested files
+
+- new VS Code-free consent store under `src/indexing/` or `src/agent-manager/`;
+- `src/KiloProvider.ts` only as a thin protocol adapter;
+- `webview-ui/src/components/settings/IndexingTab.tsx`;
+- `webview-ui/src/components/settings/indexing-tab-state.ts`;
+- `webview-ui/src/context/config.tsx` only if necessary;
+- focused tests beside existing indexing tests.
+
+### Tests
+
+- unknown project defaults off;
+- A enabled does not enable B;
+- symlink/path alias resolves to the same consent;
+- repository config cannot grant consent;
+- removing/re-adding behavior follows the documented store policy;
+- no credentials/storage settings are emitted in a Project-scope patch.
+
+## Slice 0C: revisioned config overlay backend
+
+### Required result
+
+Config reads return authoritative target descriptors and revisions. Writes use compare-and-swap and cannot overwrite external changes or a changed target.
+
+### Required changes
+
+1. Extend Kilo-owned config overlay code:
+ - `packages/opencode/src/kilocode/config/overlay.ts`;
+ - a new Kilo-owned writer under `packages/opencode/src/kilocode/config/`;
+ - `packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts`;
+ - `packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts`.
+2. Read response includes for global and project targets:
+ - canonical path;
+ - scope;
+ - exists/writable;
+ - SHA-256 revision of canonical path, existence marker, and exact bytes;
+ - raw parsed target layer;
+ - effective config and source metadata.
+3. Write request accepts exactly one scope, `set`, `unset`, and expected path/revision.
+4. Server re-resolves the authoritative target. Never trust client path as a destination.
+5. Lock by canonical target path, re-read inside lock, compare revision, patch raw JSONC, validate, and atomically replace.
+6. Return a fresh authoritative snapshot.
+7. Return typed 409 conflicts for target/revision changes; preserve drafts client-side.
+8. Keep Kilo logic in `packages/opencode/src/kilocode/`. Shared upstream files get minimal marked delegation only when unavoidable.
+9. Regenerate the SDK after endpoint changes.
+
+### Tests
+
+Add/extend:
+
+- `packages/opencode/test/kilocode/server/config-overlay.test.ts`;
+- `packages/opencode/test/kilocode/project-config-update.test.ts`.
+
+Cover:
+
+- exact raw target layer;
+- comment-only external edit conflict;
+- missing-file target revision;
+- newly created higher-priority target conflict;
+- concurrent writers: one success, one 409;
+- symlink escape rejection;
+- global and project writes remain separate;
+- atomic write failure never exposes partial content.
+
+### Stop gate
+
+From `packages/opencode/`:
+
+```sh
+bun run typecheck
+bun test test/kilocode/server/config-overlay.test.ts
+bun test test/kilocode/project-config-update.test.ts
+```
+
+From repository root:
+
+```sh
+./script/generate.ts
+bun run script/check-opencode-annotations.ts
+bun run script/check-opencode-promise-facades.ts
+```
+
+## Slice 0D: immutable Settings bindings
+
+### Required result
+
+Settings has explicit `User | Project` scope. Project scope requires an explicit trusted project selector. Drafts and saves never follow Agent Manager activation.
+
+### Required changes
+
+1. Split runtime-effective config from Settings editor config.
+2. Add a binding controller under `packages/kilo-vscode/src/kilo-provider/`; keep `KiloProvider.ts` as a thin adapter.
+3. Replace unqualified config protocol with:
+ - read request carrying request ID, scope, and optional project ID;
+ - snapshot carrying opaque binding ID and source/target metadata;
+ - write request carrying request ID, binding ID, set, and unset.
+4. The extension stores authoritative bindings. The webview never supplies a writable path.
+5. Project binding resolution:
+ - resolve registered `ProjectContext.root`;
+ - require project existence, matching generation, and trust;
+ - never use active worktree/session or `getWorkspaceDirectory()` after binding creation.
+6. Bindings expire on successful save, reconnect, project removal, generation change, or trust revocation.
+7. Separate drafts by scope/project binding.
+8. Dirty selector changes prompt Save, Discard, or Stay.
+9. Out-of-order reads/writes update only matching request/binding.
+10. External config events refresh clean drafts and mark dirty drafts stale without overwriting them.
+11. Remove `splitConfigByScope()` after all controls declare scope explicitly.
+12. Audit direct config mutators outside the save bar, including provider disconnect, imports/resets, custom providers, work styles, Permission Dock, MCP actions, and indexing.
+13. Open Project Config takes explicit `ProjectRef`, checks trust, and resolves the registered root.
+
+### UI scope policy
+
+Use the table in `agent-manager-multi-project-configuration.md`. Do not invent another policy.
+
+### Blocking tests
+
+- load A, activate B, save A: only A target changes;
+- same while selecting worktrees/sessions;
+- User save changes only user config;
+- Project save changes only selected trusted project's root config;
+- dirty draft cannot migrate to another project;
+- project removal/trust revocation expires binding;
+- external edit returns conflict and preserves draft;
+- no save-time active-directory lookup occurs.
+
+## Slice 1: canonical project identity
+
+### Required result
+
+Every project is a canonical Git repository root with URI authority and common Git directory identity.
+
+### Required changes
+
+1. Replace path-only project descriptors with:
+
+```ts
+{
+ id,
+ uri,
+ scheme,
+ authority,
+ root,
+ commonGitDir,
+ label,
+ order,
+ trusted,
+ addedAt
+}
+```
+
+2. Pinned and added projects use the same resolver:
+ - validate URI host scope;
+ - `git rev-parse --show-toplevel`;
+ - `git rev-parse --path-format=absolute --git-common-dir`;
+ - realpath/normalize both;
+ - derive ID from scheme, authority, and canonical root.
+3. Opening VS Code in a repository subdirectory still uses the Git toplevel and root state file.
+4. Reject another exposed project sharing canonical `commonGitDir`.
+5. Key Git mutation locks by common Git directory.
+6. Preserve remote URI scheme/authority in picker, storage, and open-folder operations.
+7. Migrate or safely read the current registry version without dropping valid entries.
+
+### Tests
+
+- workspace subdirectory resolves to repository root;
+- symlink aliases dedupe;
+- linked worktree/common-dir duplicate rejected;
+- other remote authority hidden but preserved;
+- missing/non-Git entry remains removable and never falls back to pinned root.
+
+## Slice 2: strict routing and lifecycle
+
+### Required result
+
+Every repository-bound operation is explicitly project-qualified once multiple projects are exposed.
+
+### Required changes
+
+1. Introduce one project envelope at the Agent Manager boundary with project ID and generation.
+2. Compatibility adapter injects pinned identity only in single-project mode.
+3. Multi-project mode rejects missing/mismatched identity with typed errors.
+4. Wire `ProjectRouteService` into every existing-session operation:
+ - transcript/messages;
+ - prompt/abort;
+ - share/unshare;
+ - fork/continuation;
+ - permission/question responses;
+ - terminal;
+ - file/context operations;
+ - diff/apply/revert.
+5. Route global SSE events once by exact directory/session ownership.
+6. Preserve non-Agent-Manager KiloProvider behavior.
+7. Execute repository mutations through `ctx.run()` and generation-check commits.
+8. Removal waits for mutation queue, stops pollers/watchers, flushes state, detaches routes, then removes registry entry.
+9. Flag-off activates pinned Local and suspends secondaries without aborting sessions.
+
+### Tests
+
+- same raw session ID across projects is ambiguous without qualifier;
+- B Local/share/unshare/permission never routes through A;
+- unknown IDs produce no terminal/file/diff operation;
+- project switch during mutation cannot change operation ownership;
+- late completion after removal cannot mutate replacement context.
+
+## Slice 3: uniform context-owned state and polling
+
+### Required result
+
+Every initialized expanded project owns the same live-state services. Active selection changes cadence/detail only, not ownership.
+
+### Required changes
+
+1. Move stats and PR pollers into `ProjectContext`; remove active singleton/background split.
+2. Each context owns stale/presence state, cached stats, local stats, PR state, run/setup state, and generation.
+3. Background presence updates project stale state rather than emitting empty arrays.
+4. All project outputs include project ID and generation; webview discards stale generations.
+5. Session-created/deleted/updated events refresh the owning project store without active-project filtering.
+6. Panel visibility and expansion uniformly control polling.
+
+### Tests
+
+- two expanded projects both receive stats/presence/PR updates;
+- collapse stops only that project's pollers;
+- stale late poll result is dropped;
+- background session deletion updates only its project.
+
+## Slice 4: one full sidebar body
+
+### Required result
+
+Single- and multi-project modes render the same full `ProjectSidebarBody` implementation.
+
+### Required changes
+
+1. Extract the current full `renderBody()` from `AgentManagerApp.tsx` intact.
+2. Parameterize it with one project store and project-qualified actions.
+3. Preserve:
+ - busy state;
+ - drag/drop and grouping;
+ - section actions;
+ - search and keyboard hints;
+ - run/setup state;
+ - New Worktree dialog;
+ - worktree menus and PR actions;
+ - complete managed/unassigned session behavior.
+4. Delete the simplified duplicate body path.
+5. Render project rows keyed by stable project ID and keep bodies mounted across active changes.
+6. New Worktree receives explicit target project, defaulting to Settings/detail selection policy as specified.
+
+### Tests
+
+- single-project behavior unchanged with flag off;
+- two expanded projects render two full bodies;
+- active selection changes no body mount identity;
+- drag/drop, sections, worktree actions, sessions, and keyboard navigation work across projects.
+
+## Slice 5: cleanup and release validation
+
+1. Remove unrelated manifest/lockfile drift.
+2. Update changeset to final user-visible behavior.
+3. Run all extension and affected CLI checks.
+4. Build/package the extension.
+5. Use the VS Code self-test fixture with two repositories and multiple worktrees/sessions.
+6. Keep feature default off until every blocking test passes.
+
+### Required extension checks
+
+From `packages/kilo-vscode/`:
+
+```sh
+bun run format
+bun run format:check
+bun run typecheck
+bun run lint
+bun run test:unit
+bun run knip
+bun run check-kilocode-change
+bun run compile
+```
+
+From repository root:
+
+```sh
+bun run script/check-opencode-annotations.ts
+bun run script/check-opencode-promise-facades.ts
+bun run script/check-md-table-padding.ts
+```
+
+### Manual scenarios
+
+1. Flag off: current single-project Agent Manager is unchanged.
+2. Enable flag in Experimental Settings; restart; value persists without CLI/project config changes.
+3. Add/trust/expand two repositories; restart; pinned remains first.
+4. Both projects show Local, worktrees, sessions, stats, PR state, and full actions.
+5. Navigate previous/next across projects.
+6. Create/delete/rename worktrees in both projects.
+7. Send prompts and answer permission/question requests in both projects.
+8. Share/unshare B while A is active; exact B directory is used.
+9. Load Project Settings A, activate B, save A; B is unchanged.
+10. Modify A config externally while draft is dirty; conflict preserves draft.
+11. Indexing defaults off for a new project; enabling A does not enable B; repo config cannot enable either.
+12. Disable flag while B is active; pinned Local becomes active and B remains registered/suspended.
+13. Repeat key routing and identity checks in a remote VS Code window.
+
+## Rules for the implementation model
+
+- Work on one slice at a time.
+- Read the referenced architecture section before editing.
+- Add focused tests before or with behavior changes.
+- Use `apply_patch` for manual edits.
+- Do not hand-edit generated SDK files.
+- Do not raise file-size caps.
+- Do not add fallback compatibility code unless the plan explicitly requires the single-project adapter.
+- Do not claim a slice complete until its stop gate passes.
+- If a required API or ownership decision is missing, stop and update this plan instead of guessing.
diff --git a/.kilo/plans/agent-manager-multi-project-runtime.md b/.kilo/plans/agent-manager-multi-project-runtime.md
new file mode 100644
index 0000000000..25bb0d9e07
--- /dev/null
+++ b/.kilo/plans/agent-manager-multi-project-runtime.md
@@ -0,0 +1,351 @@
+# Agent Manager multi-project runtime
+
+> UI architecture update: the active-body plus background-summary model in this document is superseded by `agent-manager-multi-project-uniform-ui.md`. The runtime findings remain useful, but the final UI renders one permanent real interactive body for every expanded project and requires strict project-qualified routing before background actions are enabled.
+
+**Status:** Runtime foundation implemented (this worktree); accordion UI pending
+
+## Implementation status (2026-07-20)
+
+Landed in this worktree, all covered by unit tests (`tests/unit/agent-project-*.test.ts`):
+
+- `src/agent-manager/project-paths.ts` — canonical root resolution (`realpath` + `normalizePath`), case-aware `samePath`, deterministic `projectIdFor(root)` (sha1, stable across restarts), `resolveGitRoot`.
+- `src/agent-manager/project-registry.ts` — versioned global catalog of **additional** projects (storage-injected, corrupt-safe, dedupe by id, trust/label/order). The pinned workspace project is never persisted.
+- `src/agent-manager/project-context.ts` — immutable `ProjectContext` (owns WorktreeStateManager/WorktreeManager/SetupScriptService/stale set per canonical root, lazy creation, peek accessors) and `ProjectContexts` coordinator (pinned derivation, active/expanded lifecycle, trust+flag gating, `syncPinned()` for workspace changes, webview `ProjectSnapshot`s).
+- `src/agent-manager/project-messages.ts` — vscode-free handlers: `requestProjects`, `addProject` (folder picker → git root resolve → dedupe), `removeProject`, `selectProject`, `setProjectExpanded`, `trustProject`. All fail closed when the flag is off.
+- `src/agent-manager/project-wiring.ts` — factory assembling registry/contexts/deps/host listeners (extracted to keep `AgentManagerProvider.ts` under its 2000-line cap).
+- `AgentManagerProvider` — fields `state/worktrees/setupScript/staleWorktreeIds` replaced by context-backed getters; every existing handler now operates on the active project with zero per-handler changes. `onMessage` consumes project messages first and drops messages whose `projectId` mismatches the active project (stale-pane protection). `activateProject` re-runs `initializeState` for the new context; pollers/importer/run/terminal follow the active context through the existing getters. `agentManager.state` now carries `projectId`.
+- Protocol — `agentManager.projects` out-message (`multiProject` flag + `ProjectSnapshot[]`); new in-messages listed above; `projectId?` on `agentManager.state` and `createWorktree`. Webview type mirrors updated (`webview-ui/src/types/messages/{extension,webview}-messages.ts`).
+- Host — `pickFolder`, `multiProject`, `readProjects`/`writeProjects` (globalState key `agentManager.projects`), `onDidChangeWorkspaceFolders`, `onDidChangeMultiProject`.
+- Setting — `kilo-code.new.agentManager.multiProject` (boolean, default false, application scope).
+- Changeset — `.changeset/agent-manager-multi-project.md`.
+
+Behavior with flag off: only the pinned workspace project exists; snapshots contain exactly one project; all registry data is preserved but hidden; no UI change.
+
+## Remaining for the UI session (accordion)
+
+- Project accordion rendering from `agentManager.projects`: header-only rows for collapsed/uninitialized projects (registry metadata only), full existing Local/worktree body for the active project, expanded non-active bodies from per-context state (extension must push per-context state with that context's `projectId` — currently `pushState` only pushes the active context).
+- Add-project entry point, trust CTA (sends `agentManager.trustProject` then expand/select), remove/rename in project menu.
+- `selectProject` before any repo action in a non-active project; send `projectId` on `createWorktree` (extension drops mismatches).
+- New Worktree dialog project selector (defaults to active project).
+- Extension follow-ups: hidden-cadence polling for expanded projects; session-owner index so Local sessions of project B open/share correctly even while A is active (currently Local sessions resolve through the active root — acceptable only while the UI activates before opening).
+- Implemented: per-context `pushState` for expanded non-active projects; per-project git stats/PR pollers (`ProjectPollers` in `project-pollers.ts`) so every expanded accordion receives live stats/PR data tagged with its projectId; webview `project-live.ts` store routing per-project payloads into per-project summary stores; `activate()` keeps the previously active project expanded.
+- Known boundary: `KiloProvider.projectID` and session refresh remain single-project; cross-project SSE filtering by session→directory owner is a follow-up (see "Verified findings" below).
+
+## Objective (original)
+
+Build the project-aware Agent Manager runtime before changing the UI. The eventual UI is a pinned current-project view plus lazily initialized additional project accordions, with one shared tab/detail area. The first backend slice must be mergeable with the flag disabled and must route today's single project through the same project-aware contracts that secondary projects will use.
+
+The implementation must preserve these invariants:
+
+- The first project is always the canonical Git root of the first VS Code workspace folder in the current window. It is derived at runtime, is always first, and cannot be removed or replaced by persisted state.
+- Global extension storage contains only the catalog of additional projects and project-list UI metadata. Each repository remains authoritative for its own `.kilo/agent-manager.json`.
+- A collapsed, never-opened additional project causes no `.kilo/agent-manager.json` read, Git mutation, setup/run initialization, terminal creation, or poller subscription.
+- Every session/worktree/draft operation has a project identity internally. Once secondary-project activation is enabled and more than one project is exposed, missing or mismatched identity is rejected rather than routed to the current workspace.
+- The feature flag gates catalog exposure, secondary activation, and multi-project wire/UI behavior. It does not select a separate legacy implementation.
+
+## Verified findings
+
+- `AgentManagerProvider` currently owns the panel and all repository-bound resources in one 1,941-line singleton. Its root is always `Host.workspacePath()` and its state/managers are lazily cached against that root (`packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts:56-212`, `1654-1695`). The architecture test caps this file at 2,000 lines and explicitly forbids raising the cap (`packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts:793-854`).
+- Opening the panel immediately initializes the current repository, performs `ensureGitExclude`, loads `.kilo/agent-manager.json`, discovers/restores worktrees, registers sessions, starts state-dependent behavior, and recovers prompts (`AgentManagerProvider.ts:267-360`). This whole sequence cannot run for a collapsed catalog entry.
+- `WorktreeStateManager` is correctly repository-scoped by constructor root and writes only `/.kilo/agent-manager.json` (`WorktreeStateManager.ts:100-124`, `632-656`, `797-847`). Its save queue and `flush()` are suitable for a context shutdown barrier, but it has no cancellation/dispose primitive (`755-795`).
+- The embedded Agent Manager `KiloProvider` is one provider attached to the one panel/webview (`agent-manager/vscode-host.ts:67-169`). It already routes many SDK calls through a session-to-directory map, but local sessions are allowed to fall back to `workspaceFolders[0]` (`KiloProvider.ts:4343-4378`; `kilo-provider-utils.ts:319-345`). That fallback becomes a cross-project write risk.
+- `KiloProvider` has singleton project state (`projectID`, `currentSession`, project-scoped caches, one active project directory) and current session refresh treats all extra directories as worktrees of one canonical project (`KiloProvider.ts:344-359`, `1984-2029`; `kilo-provider-utils.ts:230-295`). It is not multi-project-correct without refactoring.
+- The shared backend and generated SDK support an explicit `directory` on session share and unshare (`packages/sdk/js/src/v2/gen/sdk.gen.ts:4733-4795`). Sharing must therefore use the same project/session route as prompts and transcript reads.
+- Backend session IDs are high-entropy process-global IDs (`packages/opencode/src/id/id.ts:1-17`, `23-33`, `42-49`). Raw IDs can remain the SDK/server identifier, but the UI/runtime identity still needs a project-qualified reference, and route registration must reject an observed cross-project collision.
+- `session.status` currently discards the SSE directory before invoking branch naming (`AgentManagerProvider.ts:204-220`). The orchestration bridge subscribes globally and rejects requests outside its root (`orchestration-bridge.ts:175-203`). Creating one unchanged bridge per project would let the wrong bridge reject a valid request before the correct project handles it.
+- Multiple `SessionTerminalManager` instances would each register the same global VS Code commands and terminal listeners (`SessionTerminalManager.ts:35-81`). It must remain panel/coordinator-owned, with project-qualified keys and explicit CWDs. Also, `TerminalRouter` currently falls back from an unknown worktree ID to the repository root (`terminal-routing.ts:120-130`), which must be removed in strict routing.
+- `VscodeHost.workspacePath()` uses `workspaceFolders[0]` (`vscode-host.ts:172-174`; `review-utils.ts:12-15`). It loses URI scheme/authority, and `openFolder` recreates paths with `Uri.file`. A catalog containing only path strings would be unsafe across local, SSH, container, and other remote extension hosts.
+- Existing Kilo experimental config is CLI/project config, not an application feature-flag service (`features.ts`; `webview-ui/src/types/messages/config.ts`). A multi-project bootstrap flag cannot be project-scoped because choosing which project config to read is the behavior being gated.
+
+## Architecture decisions
+
+### 1. Project identity and registry semantics
+
+Add a VS Code-free `ProjectRegistry` backed by an injected storage and resolver interface. `vscode-host.ts` implements those interfaces using `ExtensionContext.globalState`, workspace/open-dialog URIs, filesystem canonicalization, and Git.
+
+Use these core types in `src/agent-manager/project.ts`:
+
+```ts
+type ProjectId = string
+
+interface ProjectLocation {
+ uri: string
+ scheme: string
+ authority: string
+ root: string
+ commonGitDir: string
+}
+
+interface ProjectDescriptor extends ProjectLocation {
+ id: ProjectId
+ name: string
+ pinned: boolean
+ collapsed: boolean
+ order: number
+ status: "unverified" | "ready" | "missing" | "notGit" | "wrongAuthority"
+}
+```
+
+`root` is the canonical filesystem Git toplevel. Resolve it only when deriving the pinned project, adding a project, or expanding an unverified project:
+
+1. Start from the selected workspace/folder URI.
+2. Reject a URI whose scheme/authority is not the current extension-host scope.
+3. Run `git -C rev-parse --show-toplevel` through the existing hidden-window process wrapper.
+4. Resolve the result with `fs.promises.realpath`; normalize separators, trailing separators, and Windows case for identity comparison.
+5. Resolve and canonicalize `git rev-parse --path-format=absolute --git-common-dir` as `commonGitDir`.
+6. Derive `ProjectId` deterministically as a versioned SHA-256/base64url hash of `scheme + NUL + authority + NUL + normalized canonical root`. Do not expose a path-concatenated ID on the wire.
+
+Different linked worktree roots have different project IDs, but the first iteration must reject adding a project whose canonical `commonGitDir` matches any project already exposed in the window. Two Agent Manager contexts mutating the same Git common directory would bypass the current root-keyed worktree lock and could manage the same branches twice. This is a product-valid restriction to distinct Git projects, not a path workaround. Also change `WorktreeManager`'s write-lock key to canonical `commonGitDir` so future relaxation does not reintroduce `index.lock` races.
+
+Persist one unsynced global-state value under `kilo.agentManager.projects.v1`:
+
+```ts
+interface StoredProjectCatalogV1 {
+ version: 1
+ projects: Array<{
+ id: ProjectId
+ uri: string
+ scheme: string
+ authority: string
+ root: string
+ commonGitDir: string
+ name?: string
+ collapsed: boolean
+ order: number
+ addedAt: string
+ }>
+}
+```
+
+Do not call `setKeysForSync`; machine/remote paths must not sync to other machines. Serialize mutations in-process. Each mutation re-reads the latest value, validates it, applies add/remove/reorder/metadata changes, and performs one `globalState.update`.
+
+Registry behavior is exact:
+
+- `snapshot(flag)` derives the pinned current project fresh. It never reads a persisted active project.
+- The pinned descriptor is first, has `pinned: true`, and defaults to expanded. It is not written into the catalog.
+- Persisted entries matching the pinned project ID are suppressed, not deleted. They reappear as additional projects when another window has a different pinned project.
+- Additional entries are filtered to the current URI scheme/authority before exposure. Entries for other authorities remain untouched in storage.
+- Reading the catalog does not stat, realpath, run Git, or open per-repository state. Persisted additional entries start as `unverified`.
+- Adding validates/canonicalizes the chosen folder, rejects the pinned project, duplicate IDs, duplicate common Git directories, and authority mismatches, then persists it. A newly added project may be marked expanded and explicitly activated by the caller; listing alone never initializes it.
+- A missing or no-longer-Git root is marked unavailable only when expansion validates it. Keep the entry and its metadata. Do not prune it automatically and do not create a `ProjectContext`.
+- Removing is allowed only for non-pinned projects. It removes only the catalog entry after the context is idle/disposed; it never deletes `.kilo`, worktrees, sessions, branches, or shares.
+- On every extension/panel restart, the active selection is the pinned project's Local context. Persisted additional projects and collapse/order/name metadata survive, but a stale secondary active session does not override the current VS Code window.
+
+For a multi-root VS Code workspace, preserve current behavior by deriving the pinned project from `workspaceFolders[0]`, then canonicalize that folder to its Git toplevel. Do not silently select another workspace folder if the first is not a Git repository.
+
+### 2. Coordinator and immutable lazy ProjectContext
+
+Turn `AgentManagerProvider` into the panel-level coordinator and extract repository behavior into `project-context.ts`. Do not instantiate one current `AgentManagerProvider` per project.
+
+The coordinator owns:
+
+- the one panel and embedded `KiloProvider`/`SessionProvider`;
+- `ProjectRegistry`, active `ProjectId`, exposed descriptors, and `Map`;
+- a shared `Semaphore(3)` for Git/network polling across all projects;
+- the one project/session router;
+- one project-aware `AgentManagerVisiblePresence`;
+- one `SessionTerminalManager` and one project-aware `TerminalRouter`;
+- one global orchestration ingress and the global SSE/tool/status subscriptions;
+- project and activation generations used to discard stale async results.
+
+Each `ProjectContext` is constructed once with an immutable descriptor/root/common Git directory and owns only repository-bound resources:
+
+- `WorktreeStateManager`, `WorktreeManager`, `SetupScriptService`, and repository `GitOps`;
+- local diff functions, `WorktreeDiffController`, `WorktreeImporter`, and `RunController`;
+- `GitStatsPoller`, `PRStatusBridge`/poller, and `BranchNamingController`;
+- per-project cached state/stats/stale IDs, managed/open session refs, and initialization promise;
+- project-specific message handlers currently embedded in `AgentManagerProvider`.
+
+It does not own a webview, `KiloProvider`, VS Code terminal command registrations, global connection subscriptions, or a global orchestration subscription.
+
+Lifecycle:
+
+1. `cold`: descriptor only. No state manager, manager, poller, watcher, or per-repo read exists.
+2. `initializing`: one shared promise validates the location, constructs resources, performs the existing `ensureGitExclude -> state.load -> recoverWorktrees -> register routes` sequence, and captures a context generation.
+3. `ready/expanded`: posts project-stamped state and permits project actions. Pollers run only when both the panel is visible and this project is expanded. Multiple expanded contexts may be ready concurrently.
+4. `suspended/collapsed`: if active, the coordinator first activates pinned Local (or another explicit target). Stop stats/PR/diff polling and watchers, stop accepting new project mutations, wait for the current mutation barrier, flush state, and detach project-specific event side effects such as automatic branch naming. Keep validated session routes/tracking so a running backend session can still surface status and permission/question events without a repository read. Do not abort agents merely because an accordion collapsed.
+5. `disposed`: increment generation first, stop/dispose resources, await terminal-router/run/mutation cleanup and state flush, detach routes, then remove the exact context object from the map. Late completions may neither post nor mutate a replacement context.
+
+Expansion of a suspended context resumes its existing in-memory state and pollers; expansion of a cold context initializes. Concurrent expansions share the same promise. Collapse during initialization records the desired collapsed state, allows non-cancellable Git/state work to finish, suppresses stale posts, then immediately suspends.
+
+The coordinator routes global events by canonical event directory before invoking a context. `session.status` must retain its directory. A single orchestration ingress determines the one owning context and delegates there; it rejects an unknown directory once, globally. It must never create one rejecting bridge per project. A collapsed project does not auto-initialize in response to an unsolicited orchestration event; return a typed `project_inactive` result.
+
+Panel close keeps existing semantics for sessions actually opened/created by that panel, but aggregates their project-qualified routes before aborting. Project collapse and flag-off transitions do not abort them. Removing a project is refused while one of its tracked sessions or Agent Manager run/mutation is busy, rather than orphaning an interaction prompt.
+
+### 3. One embedded KiloProvider, made project-aware
+
+Use one embedded `KiloProvider`. Multiple providers are not safe or useful here:
+
+- there is one webview and one message handler;
+- each provider would own conflicting current-session/cached UI state and duplicate SSE handling;
+- multiple terminal/visibility registrations would be ambiguous;
+- it would not solve session identity at the shared webview boundary.
+
+Refactor its Agent Manager adapter around an injected project/session route service while leaving sidebar and standalone providers unchanged. The route service supplies:
+
+```ts
+interface ProjectSessionRef {
+ projectId: ProjectId
+ sessionId: string
+}
+
+interface SessionRoute extends ProjectSessionRef {
+ directory: string
+ generation: number
+}
+```
+
+Internally key UI maps by `projectId + NUL + sessionId`; never use that concatenation on the wire. Keep the raw `sessionId` for SDK calls and backend presence. Maintain a reverse raw-ID index only to process backend events. If the same raw ID is observed in two projects, mark it ambiguous and reject/drop operations and events that cannot also be disambiguated by event directory.
+
+Required routing changes:
+
+- Register an explicit route for every Agent Manager session, including Local sessions at a project root. Do not omit a map entry merely because it equals the first workspace root.
+- New/draft operations resolve from the outer project envelope and an explicit Local/worktree context. Unknown worktree IDs are errors; remove `TerminalRouter`'s fallback to root.
+- Existing session operations resolve only from `ProjectSessionRef`. In strict multi-project mode, an unknown session never falls back to `workspaceFolders[0]` or the currently active project.
+- Child/subagent sessions inherit the parent's project and directory. A directory-bearing event must also pass the owning-context check before registration.
+- Session list refresh runs per project root plus that project's worktree directories. It emits sessions stamped with that project; it does not infer one canonical `projectID` from the first root and merge all projects into it.
+- Replace singleton Agent Manager use of `KiloProvider.projectID` with the route table/directory ownership check. Keep existing project filtering for non-Agent-Manager provider instances.
+- On active-project/session change, call one `activateProject(projectId, root)` path. It updates `projectDirectory`, invalidates/refetches project-scoped config, providers, agents, commands, skills, MCP, indexing, sandbox, and Git status for that root. Guard every async refresh with activation generation so project A cannot populate caches after switching to B.
+- Every session create, continuation, prompt, permission/question response, terminal operation, diff operation, file/context operation, and share/unshare SDK call receives the resolved explicit directory.
+
+This picks up each secondary project's config naturally through the CLI's directory-scoped instance/config resolution. Do not copy provider/config/settings data into global storage.
+
+### 4. Project-qualified protocol and share semantics
+
+Define an internal/wire envelope in `agent-manager/types.ts` and mirror it in `webview-ui/src/types/messages/agent-manager.ts`:
+
+```ts
+interface ProjectStamp {
+ projectId: ProjectId
+ generation: number
+}
+
+interface ProjectEnvelope {
+ type: "agentManager.projectMessage"
+ project: ProjectStamp
+ payload: T
+}
+```
+
+Use project-qualified references for sessions, worktrees, and drafts:
+
+```ts
+interface SessionRef { projectId: ProjectId; sessionId: string }
+interface WorktreeRef { projectId: ProjectId; worktreeId: string }
+interface DraftRef { projectId: ProjectId; draftId: string }
+```
+
+Raw IDs remain in each repository's existing state file. The repository/context supplies the project identity when state is loaded. Do not migrate session IDs into global state and do not permit moving an existing session record between projects. A future cross-project continuation must explicitly fork/create in the target project; relabeling a raw session is invalid.
+
+Protocol rules:
+
+- The coordinator is the only component that unwraps inbound envelopes and wraps context output.
+- Global catalog/route errors are global messages. Repository state, stats, session metadata, diff, run, PR, terminal, and chat messages are project envelopes.
+- Preserve `requestId` inside the payload. Return a typed route error (`project_required`, `project_unknown`, `project_inactive`, `project_mismatch`, `session_unknown`, or `session_ambiguous`) without forwarding the original message.
+- Validate the envelope project against the context, persisted session/worktree ownership, session route, and event/request directory. A valid raw ID in the wrong envelope is still rejected.
+- Outbound generation lets the webview/coordinator discard messages from a collapsed/disposed/replaced context.
+- While the flag is off, or while only the pinned project is exposed, a compatibility adapter wraps today's unqualified webview messages with the pinned project and unwraps pinned output to the existing shape. The core handlers still receive envelopes.
+- Once the flag is on and a second project is exposed, unqualified project/session/worktree messages fail closed. The multi-project UI must send/consume envelopes and use composite refs as tab/store keys.
+
+Sharing is an operation on `SessionRef`, not on a bare session ID:
+
+- Manual share calls `client.session.share({ sessionID, directory })`; unshare uses the exact same resolved route.
+- Automatic sharing remains a project config behavior because session creation is sent with the target project's directory.
+- Store no share URL or share state in the project catalog. Session list/get/update data remains authoritative and is enriched with `projectId` on output.
+- Removing/collapsing a project never unshares its sessions. A share URL is externally usable, but reopening or mutating the local session still requires its local project route.
+- A mismatched or missing project cannot share/unshare, even if the raw session ID happens to resolve elsewhere.
+
+### 5. Feature flag, restart, and migration behavior
+
+Contribute an application-scoped VS Code setting, default false, for example `kilo-code.new.agentManager.multiProject`, with `scope: "application"`. Read it in the host/coordinator; do not put it in CLI `experimental` config.
+
+- Flag off: derive and initialize only pinned current project, use the compatibility wire adapter, and preserve the exact current view. Read but do not expose, validate, initialize, rewrite, or delete additional catalog entries.
+- Flag on: publish lightweight descriptors. Only explicit expansion initializes an additional project. Selecting Local/worktree also expands and initializes its owner before activation.
+- Runtime on -> off: increment activation generation, activate pinned Local, suspend all secondary contexts, switch to compatibility protocol, and preserve catalog/per-repo state. Do not abort backend sessions.
+- Runtime off -> on: expose stored descriptors as cold/unverified. Do not eagerly restore a persisted secondary active project.
+
+No `.kilo/agent-manager.json` schema migration is needed. Existing state is loaded unchanged by the pinned context, and additional contexts load their own existing files only when expanded. An absent catalog means no additional projects. Invalid catalog entries are ignored in the snapshot and logged, but a successful write must preserve valid entries from other authorities. Never scan the filesystem to discover projects as migration.
+
+On restored legacy panel/webview state, unqualified tabs are accepted only in compatibility single-project mode. When multi-project mode exposes a second project, send a protocol reset and rebuild tabs/session lists from project envelopes instead of guessing which project owns a restored raw session ID.
+
+Remote behavior:
+
+- Scope catalog exposure and identity by full URI scheme + authority, not `vscode.env.remoteName` alone.
+- Run Git and filesystem operations in the current extension host against `fsPath`; preserve the original URI for `openFolder` and picker operations.
+- Reject selection from another authority. Keep such stored entries for their matching window/host.
+- Unsupported virtual filesystems, missing roots, permission failures, and non-Git directories become unavailable descriptors after explicit validation. They do not fall back to the pinned root.
+
+## Ordered implementation plan
+
+### Slice A: mergeable routing foundation before multi-project UI
+
+This is the narrow first PR. It produces no secondary-project UI, but the current pinned project runs through the durable identity/routing contracts and the registry is fully tested.
+
+1. Add `src/agent-manager/project.ts` with IDs, locations, descriptors, stamps, envelopes, and ref types. Add `src/agent-manager/project-registry.ts` with injected storage/resolver, v1 schema validation, pinned merge, authority filtering, dedupe, and serialized mutations.
+2. Extend `Host` in `src/agent-manager/host.ts`; implement global-state storage, canonical Git/URI resolution, pinned project resolution, and the application flag in `src/agent-manager/vscode-host.ts`. Preserve URIs in `openFolder`. Add the setting contribution in `packages/kilo-vscode/package.json`.
+3. Add `src/agent-manager/project-session-router.ts`. It must register Local and worktree routes, resolve refs/directories, inherit child routes, validate directory ownership, detect raw-ID ambiguity, and implement compatibility versus strict mode.
+4. Add envelope/route errors to `src/agent-manager/types.ts` and the mirrored webview type file. Add a coordinator adapter in `AgentManagerProvider` that derives the pinned project at panel attach, wraps all legacy inbound messages, stamps context output, and switches strictness based on exposed-project count. In this slice only the pinned context is activated, so rendered behavior remains unchanged.
+5. Extend the `SessionProvider` adapter (`host.ts`, `vscode-host.ts`) with project-aware `setSessionRoute`, `trackSession`, `getSessionInfo`, `loadMessages`, `share`, and `unshare` methods. Retain temporary legacy methods only as single-project adapters, not as a second implementation.
+6. Modify `KiloProvider`, `kilo-provider/options.ts`, and the extracted helpers in `kilo-provider-utils.ts` so Agent Manager Local sessions also have explicit routes, unknown strict routes fail closed, child routes inherit project identity, and project activation invalidates project-scoped cache work by generation. Keep non-Agent-Manager provider behavior unchanged.
+7. Update `extension.ts` auto-approve/session-directory lookup to ask the router/coordinator for the active or unique `SessionRef`; do not merge raw ID maps and choose the first match.
+8. Remove unknown-worktree-to-root fallback in `terminal-routing.ts`, and inject the pinned project root into `createTerminalHost` instead of reading `workspaceFolders[0]` internally.
+
+Slice A is complete when the flag-disabled Agent Manager is visually and behaviorally unchanged, every current project session has an explicit project route, and unit tests prove a second registered route cannot be accidentally handled through pinned-root fallback. Do not expose Add Project until Slice B is complete.
+
+### Slice B: lazy multi-context runtime, still before UI rendering
+
+1. Add `src/agent-manager/project-context.ts` and move root-bound state/managers/handlers out of `AgentManagerProvider`. Keep context code VS Code-free. Reduce the provider cap to the new rounded-up line count instead of raising it.
+2. Make `AgentManagerProvider` the coordinator described above. Add cold/initializing/ready/suspended/disposed state, generation guards, expansion/activation APIs, shared semaphore, and per-project message dispatch.
+3. Refactor status/tool/orchestration subscriptions so the coordinator routes once by project/directory. Do not retain per-context global subscriptions that can reject each other's requests.
+4. Make terminal management panel-global and key mappings by composite session/worktree refs. A context supplies only an already-validated CWD. Ensure context collapse cannot switch to another project's terminal by raw ID.
+5. Refactor session refresh to accept one project at a time and emit project-stamped results. Register routes for discovered Local sessions before they can be opened. Preserve failed-directory session IDs only within the affected project.
+6. Change `WorktreeManager`'s mutation lock key to canonical common Git directory and reject adding another exposed context with the same common directory.
+7. Add backend catalog commands (`requestProjects`, `addProject`, `removeProject`, `expandProject`, `collapseProject`, `activateProject`) behind the flag. These are testable protocol endpoints but need not yet have visible controls.
+
+### Slice C: first usable flagged UI
+
+After A and B are green, implement project accordions and the project selector in the New Worktree modal. Keep the current markup/layout when only the pinned project exists. Use composite refs in webview stores/tabs, one shared tab bar/detail pane, and clearly show the project name on cross-project session tabs and share actions. This UI slice is intentionally outside the backend-first scope.
+
+## Exact tests
+
+Add or update these focused tests under `packages/kilo-vscode/tests/unit/`:
+
+- `agent-manager-project-registry.test.ts`: pinned project is always first/fresh; persisted active cannot replace it; additional roots survive restart; pinned duplicate is suppressed; add canonicalizes subdirectories/symlinks; duplicate root/common Git dir and wrong authority are rejected; flag-off snapshot exposes only pinned without mutating catalog; catalog listing performs no resolver/filesystem calls; missing entries remain stored.
+- `agent-manager-project-router.test.ts`: every Local/worktree session has a route; matching refs resolve; wrong project, unknown session, unknown worktree, missing project, and raw-ID collision fail closed; child inherits parent project/directory; compatibility inference works only for one exposed project; share and unshare use the same explicit directory.
+- `agent-manager-project-context.test.ts`: collapsed cold projects construct/read nothing; concurrent expand initializes once; two expanded contexts use separate state roots and share the concurrency gate; collapse stops pollers and flushes; collapse during init suppresses posts; disposed-generation results cannot update a replacement context; remove is blocked while busy.
+- Extend `kilo-provider-session-refresh.test.ts`: lists per project instead of merging project roots; outputs project stamps; failed worktree listing preserves only that project's sessions; activation generation drops stale config/session results.
+- Extend `kilo-provider-worktree-context.test.ts`: strict unknown session/worktree has no workspace fallback; Local routes explicitly resolve to their own project root; a secondary Local session cannot resolve to pinned root.
+- Extend `AgentManagerProvider.spec.ts`: single-project legacy messages are wrapped pinned; two-project unqualified messages return `project_required`; mismatched session/project returns `project_mismatch`; status/tool events dispatch by directory; restart selects pinned Local.
+- Add terminal routing coverage: unknown project/worktree produces an error and no PTY/VS Code terminal; identical raw session IDs in different projects cannot select the wrong terminal.
+- Extend `agent-manager-arch.test.ts`: new domain modules do not import `vscode`; lower `AgentManagerProvider.ts` maxLines after extraction.
+
+Run from `packages/kilo-vscode/` after each slice:
+
+```sh
+bun test tests/unit/agent-manager-project-registry.test.ts tests/unit/agent-manager-project-router.test.ts tests/unit/kilo-provider-session-refresh.test.ts tests/unit/kilo-provider-worktree-context.test.ts
+bun run typecheck
+bun run lint
+bun run test:unit
+bun run knip
+bun run check-kilocode-change
+```
+
+For Slice B, add the context/provider/terminal tests to the focused first command. No CLI/SDK regeneration is required because this architecture uses existing explicit-directory SDK parameters and adds only extension/webview protocol types.
+
+## Manual validation after the UI slice
+
+1. With the flag off and a non-empty stored catalog, restart VS Code. Agent Manager must look and behave exactly as today and use only the current window's Git root.
+2. Turn the flag on, add a second repository, restart, and verify its header persists while the pinned current project remains first and active. Confirm the collapsed second project creates no output/state access/poll traffic until expanded.
+3. Expand both projects, open Local and worktree sessions from each, switch rapidly, send prompts, answer permission/question requests, open terminals/diffs, and share then unshare. Verify every operation affects the displayed project's repository and config.
+4. Disable the flag while a secondary project is active. Verify pinned Local becomes active, secondary state remains on disk/catalog, and re-enabling restores the descriptor without eager initialization.
+5. Rename/remove a cataloged directory and restart. Verify the project remains as unavailable, cannot route actions to pinned root, and can be removed without touching its repository state.
+6. Repeat in a remote window. Verify local/other-authority catalog entries are hidden but preserved and folder opening retains the remote URI authority.
+
+## Risks and rollback
+
+- The largest correctness risk is a hidden workspace-root fallback in `KiloProvider` or a helper. Treat every Agent Manager method that accepts `sessionID`, `worktreeId`, `draftID`, `requestID`, or directory as part of the routing audit. Tests should fail if strict mode calls `workspaceFolders[0]` for an identified operation.
+- Project-scoped KiloProvider caches can show stale settings after a rapid project switch. Activation generation checks are required before posting and before replacing caches; merely cancelling transcript load is insufficient.
+- Global event consumers can race. There must be exactly one ownership decision for orchestration/tool events, and directory must be retained for status/permission/question handling.
+- Multiple contexts increase polling/process pressure. Share the concurrency semaphore, gate all pollers by panel visibility and expansion, and suspend on collapse/flag-off.
+- Global-state updates from separate VS Code windows do not provide a transactional CAS. The first iteration should re-read before each serialized mutation and tolerate another window appearing after reload; do not build a filesystem lock or custom database for this feature.
+- Rollback is safe: turn off the application flag. The pinned path and existing `.kilo/agent-manager.json` format remain intact, while the unused global catalog is preserved for a later re-enable.
diff --git a/.kilo/plans/agent-manager-multi-project-shipping-gaps.md b/.kilo/plans/agent-manager-multi-project-shipping-gaps.md
new file mode 100644
index 0000000000..3659b7d335
--- /dev/null
+++ b/.kilo/plans/agent-manager-multi-project-shipping-gaps.md
@@ -0,0 +1,201 @@
+# Agent Manager Multi-Project — Shipping Gaps
+
+Status as of 2026-07-27. The multi-project sidebar renders and works end to end in the
+self-test harness (both projects list worktrees/sessions/sections, section creation
+persists, worktree delete works, project switching restores selection, live session
+upsert verified). Full unit suite green (3444 pass / 0 fail), typecheck, lint, knip,
+arch caps all pass. Everything is uncommitted in the worktree.
+
+This document lists what is still missing, ranked by whether it should block shipping.
+
+Two audiences matter for the blocking decision:
+
+- **All users**: the branch changes shared surfaces (legacy sidebar refactor, config
+ write path, indexing consent, pollers). Regressions here ship to everyone, flag off
+ or not.
+- **Experimental users**: multi-project mode is gated behind
+ `kilo-code.new.experimental.multiProject`, default off. Rough edges here are
+ acceptable if they cannot corrupt data.
+
+---
+
+## 1. Blocks shipping (all-user surfaces)
+
+### 1.0 Empty-state skeleton regression (fixed, must stay fixed)
+
+`initializeState` and `onRequestState` only called `refreshSessions()` when the
+managed state contained at least one session. With zero managed sessions the
+backend listing never ran, `sessionsLoaded` never reached the webview, and both
+the WORKTREES and SESSIONS sections stayed on skeletons forever (the worktree
+gate requires `worktreesLoaded() && sessionsLoaded()`). Any user whose state file
+lost its sessions — exactly what the earlier session-persist bugs caused — hit a
+permanently empty-looking sidebar. Reproduced in the harness on a worktree-root
+workspace and fixed by making both refreshes unconditional. Root-caused via
+stage-by-stage init logging on 2026-07-27; do not reintroduce a content-based
+guard here.
+
+### 1.1 Config write path can fail or wipe drafts for existing callers — P0-6 / P1-7
+
+The config binding rework requires every `updateConfig` sender to carry a binding id,
+and `configUpdateFailed` currently wipes the draft for the failed scope. Any existing
+sender without a binding (permission dock, model picker, auto-approve, onboarding)
+now fails or loses the user's unsaved edits.
+
+- Fix: absent binding id falls back to the legacy revision-less write; keep revision
+ enforcement only where a binding was actually supplied. Keep the draft for scopes
+ that did not complete.
+- Work: the code change is small; the real work is auditing every webview
+ `updateConfig` sender and classifying it.
+- Also: this change is orthogonal to multi-project. Split it into its own commit
+ (`feat(vscode): config write revision bindings`) so it can be reverted alone.
+
+### 1.2 Indexing status read silently revokes consent — P0-3 / P0-4
+
+`fetchAndSendIndexingStatus` issues `PUT /indexing/consent` (a write) on a plain
+status refresh, defaulting to `enabled: false` for any project not in local
+`globalState`. On a fresh machine/profile, the first status read turns indexing off
+for users who had it on. A refresh can also target the wrong project
+(`requestIndexingStatus` resolves from the current session's directory). Separately,
+the Indexing tab lists untrusted projects and would let a user enable indexing for a
+repo the trust system has not approved.
+
+- Fix: read status with a GET; PUT only from the explicit setter; seed consent from
+ the effective config on first read instead of defaulting false; require an explicit
+ project on refresh; filter the project list to trusted projects.
+- Dependency: needs a read-only status endpoint. If none exists, this escapes into
+ `packages/opencode` (shared upstream code, needs `kilocode_change` markers) or the
+ cloud repo.
+- Also orthogonal to multi-project; split into its own commit
+ (`feat(vscode): per-project indexing consent`).
+
+### 1.3 Land the current work as separate, revertable commits
+
+Everything currently sits uncommitted in one worktree. The review's recommendation
+stands: `git reset --soft origin/main` and stage by path into three commits —
+multi-project, config bindings, indexing consent. The multi-project commit message
+must not promise per-project config/indexing behavior that lands in the other two.
+
+---
+
+## 2. Should fix soon after (experimental surface, data-integrity relevant)
+
+### 2.1 Same repo can register twice — P0-5
+
+`projectIdFor` hashes the canonical path verbatim while `samePath` folds case, so two
+casings of one repo produce two project ids, two contexts, and two state managers
+racing to write the same `.kilo/agent-manager.json` (last write wins, worktrees and
+sessions vanish).
+
+- Fix: fold case inside `projectIdFor` on darwin/win32; reject `addProject` when
+ `samePath` matches any registered root.
+- Migration: existing registry keys were built from the old hash, so re-key them on
+ load or dual-lookup on read. Note `canonicalizePath` already realpaths existing
+ paths; the fallback branch must fold case too.
+- Why not blocking: requires an unusual casing mismatch at registration time, and the
+ feature is flag-gated.
+
+### 2.2 Route service is shared across panels but versioned per context — P0-7
+
+Two VS Code windows each have their own `ProjectContexts` with independent generation
+counters feeding one shared `ProjectRouteService`. Panel B registering a project at
+generation 0 while panel A is at 2 unregisters panel A's routes; closing one panel
+drops routes the other still needs.
+
+- Fix: panel-qualified keys (`panelId + projectId + sessionId`), generations issued
+ by the service, ambiguity computed across all panels.
+- Why not blocking: needs two windows running Agent Manager against the same repos.
+ The fallback already refuses ambiguous raw ids instead of resolving them wrong.
+- Note: no two-panel test harness exists today; this fix should create one.
+
+### 2.3 `gh pr` noise in repos without remotes — Bug 5
+
+`gh pr view` fails every 15s per worktree forever, logs before the dedupe, and
+`pollOnce` rejections are unhandled.
+
+- Fix: log after the `lastHash` dedupe; `void this.pollOnce().catch(log)` in
+ `schedule`; per-root remote probe with a single error emission; skip PR pollers for
+ remote-less projects in `ProjectPollers.sync`.
+
+---
+
+## 3. UX papercuts in experimental mode (fix opportunistically)
+
+- **Legacy tabs orphaned on upgrade** (P1-1): `createLocalTabs` migrates persisted
+ `localSessionIDs` into a `single` bucket that `tabKey()` never reads once the
+ catalog arrives. Migrate `single` to the pinned project id on first state apply.
+- **`restoreProjectTarget` skips tab bookkeeping** (P1-3): a restored session has no
+ tab. Call `selectLocal()` first, add the tab, then select.
+- **`ensurePendingTab` runs before restore** (P1-4): switch adds a "New Session"
+ draft that restore may contradict. Move it after restore.
+- **Per-keystroke state saves**: `setActiveTarget` writes
+ `.kilo/agent-manager.json` on every selection change. Debounce or persist on
+ deactivation/dispose only.
+- **Untranslated Indexing tab strings** (P1-9), **unused-ish composite id schemes**
+ (P1-12), **stats messages tagged at emit time** (P1-14), **no presence sync for
+ background projects** (P1-15), **registry read cache** (P1-16/17), **realpath
+ syscall churn** (P1-18), **`resolveProjectRoot` process spawning** (P1-19).
+
+---
+
+## 4. Structural debt (schedule, don't block)
+
+### 4.1 Two sidebar implementations — Arch 1.1
+
+`AgentManagerApp` keeps the legacy `renderBody` (now `SidebarBody.tsx`) and
+`ProjectSidebarBody.tsx` as a reduced reimplementation. The reimplementation already
+caused one full outage of multi-project mode (missing `DragDropProvider` crashed the
+webview render). Missing versus legacy: worktree ordering, drag-and-drop reorder and
+move-to-section, grouping, busy/navHint/shortcut badges, section auto-rename, stats
+skeletons.
+
+- Direction: single-project becomes the degenerate case of multi-project (one
+ implicit pinned project, header row hidden), `SidebarBody.tsx` is deleted.
+- Do this last: it churns the same message-stamping code as 2.2, needs per-project
+ DnD state, and must keep legacy pixel-identical for the default-off population.
+ Use the visual-regression skill to cover both modes before merging it.
+
+### 4.2 Ambient project scope via AsyncLocalStorage — Arch 1.3
+
+`ProjectScope` plus the `this.state`/`this.context` getters make the target project
+invisible at call sites; any continuation escaping ALS silently falls back to the
+active project. `provider-lifecycle.ts` shows the intended end state (explicit deps).
+Keep threading `ctx` explicitly into the remaining handler groups; add a dev-mode log
+in the `context` getter when a project-stamped message resolves without a scope.
+
+### 4.3 Per-project webview store — Arch 1.2
+
+`worktrees()`, `managedSessions()`, `selection()` etc. are single-valued with
+`memKey()`/`tabKey()` and two "current project" accessors that disagree during the
+switch window. Long-term: one `createProjectStore(projectId)` per project. For now
+the gating added to the remember effect contains the known race.
+
+---
+
+## 5. Verification gaps to close before the PR
+
+- **SSE session upsert** is unit-tested (`upsertSession`, `byDirectory`, fresh-skip
+ re-post) but not E2E-verified: the harness backend's basic-auth credentials could
+ not be re-extracted after a window reload, so external session creation was not
+ exercised live. Verify manually: `kilo` a new session in a registered repo from a
+ terminal and watch it appear in that project's sidebar.
+- **Tab isolation across projects** (per-project buckets) is unit-tested but not
+ E2E-verified with real sessions open in two projects.
+- **Legacy mode parity**: legacy sidebar and tab bar were smoke-tested (render,
+ select, section create + inline rename), but not the full matrix (delete, DnD
+ reorder, move-to-section, review tab, terminals). The extraction moved ~700 lines
+ of JSX; a visual-regression pass over `SidebarBody`/`TabBar` stories is the
+ cheapest safety net.
+- **Harness instability**: the isolated VS Code window crashed repeatedly during this
+ work. If it keeps failing on the next pass, say so in the PR rather than claiming
+ coverage that does not exist.
+
+---
+
+## Proposed landing sequence
+
+1. Quick independent fixes: 2.3 (gh noise), P0-4 (trust filter), 1.1's audit + 1.2's
+ fallback (config), 1.2's indexing read/write split if the GET endpoint exists.
+2. 2.1 (case-fold + registry migration).
+3. 2.2 (route service, with a two-panel test harness).
+4. 1.1's config/indexing commits split out and merged separately.
+5. Arch 1.1 (sidebar collapse) with visual-regression coverage; then the P1 batch.
diff --git a/.kilo/plans/agent-manager-multi-project-uniform-ui.md b/.kilo/plans/agent-manager-multi-project-uniform-ui.md
new file mode 100644
index 0000000000..5beb0e3ef2
--- /dev/null
+++ b/.kilo/plans/agent-manager-multi-project-uniform-ui.md
@@ -0,0 +1,740 @@
+# Agent Manager multi-project uniform UI architecture
+
+**Status:** Ready, corrected architecture; implementation pending
+
+**Date:** 2026-07-22
+
+This plan supersedes the active-body plus background-summary UI described in `agent-manager-multi-project-runtime.md`. The existing project registry, immutable project-root concept, per-project state payloads, and project-tagged stats work are useful foundations. The current read-only `ProjectSummary` and active-project dynamic getter routing are not the target architecture.
+
+Configuration reads, writes, scopes, drafts, trust, revisions, and release-blocking tests are specified canonically in [`agent-manager-multi-project-configuration.md`](./agent-manager-multi-project-configuration.md). If this plan and that document differ on configuration behavior, the configuration document wins.
+
+The step-by-step implementation sequence from the current branch is [`agent-manager-multi-project-implementation-handoff.md`](./agent-manager-multi-project-implementation-handoff.md). Implementers should follow that handoff slice by slice rather than treating this architecture document as an unordered task list.
+
+## Product requirement
+
+Every expanded project permanently renders the real existing Agent Manager sidebar UI:
+
+- the real Local card;
+- the real worktree cards, including their current menus and actions;
+- the real session list, which may initially use a simplified layout but must contain real live session data;
+- live git stats, PR state, run/setup state, stale state, and session state owned by that project.
+
+Selecting or interacting with a project may change the shared detail pane and top toolbar. It must not remove, replace, downgrade, or remount any expanded project body. There is no active-body versus background-summary rendering mode.
+
+The shared detail pane remains singular. The sidebar displays all expanded projects concurrently; the detail pane displays one selected project/local/worktree/session target at a time.
+
+## Architecture verdict on the current worktree
+
+The current implementation is a valid partial runtime foundation, but it is not safe to extend directly into fully interactive background cards.
+
+Keep and evolve:
+
+- `ProjectRegistry` and the pinned-workspace project concept;
+- immutable root ownership in `ProjectContext`;
+- `initContextState` extraction;
+- project-stamped state/stats/PR messages;
+- per-project webview stores;
+- reusable `WorktreeItem`, `SectionHeader`, `WorktreeSectionActions`, and `UnassignedSessionsSection` components;
+- the application-scoped feature flag.
+
+Replace or refactor before exposing interactive multi-project UI:
+
+- delete `ProjectSummary`; do not add more behavior to it;
+- remove the rule that only `project.active` renders `renderBody()`;
+- stop routing normal actions through `contexts.active()` and optional `projectId`;
+- replace raw session/worktree/local UI keys with project-qualified references;
+- replace the active singleton poller plus background poller split with uniform context-owned polling;
+- make context initialization, mutation, suspension, and disposal explicit and generation-guarded;
+- make the one embedded `KiloProvider` project/session-route aware.
+- separate activation-bound runtime config from the Settings editor's immutable read/write binding.
+
+## Core model
+
+### Project identity
+
+A project is one canonical Git repository root in the current extension host.
+
+```ts
+type ProjectId = string
+
+interface ProjectRef {
+ projectId: ProjectId
+}
+
+interface WorktreeRef extends ProjectRef {
+ worktreeId: string
+}
+
+interface SessionRef extends ProjectRef {
+ sessionId: string
+}
+
+type SidebarTarget =
+ | { projectId: ProjectId; kind: "local" }
+ | { projectId: ProjectId; kind: "worktree"; worktreeId: string }
+ | { projectId: ProjectId; kind: "session"; sessionId: string }
+```
+
+Raw backend and repository-local IDs remain unchanged. Composite references are required at every panel/runtime boundary so identical local sentinels, worktree IDs, section IDs, or session IDs cannot cross projects.
+
+Project identity must include the extension-host URI scheme and authority in addition to the canonical root. Adding a project must also resolve canonical `git-common-dir` and reject another exposed project sharing that common Git directory. Linked worktrees of one repository cannot be registered as independent projects in the first release.
+
+### Active project means detail selection only
+
+`activeProjectId` is not a rendering mode and not an implicit mutation target.
+
+It means:
+
+- which project owns the current shared detail/chat/diff/terminal pane;
+- which project the global top toolbar and global New Session/Run buttons target;
+- which project/worktree directory supplies runtime-effective Kilo configuration in the one embedded `KiloProvider`.
+
+It never selects a Settings write target. Settings bindings are explicit and remain unchanged when detail selection changes.
+
+Project expansion controls rendering. Every `project.expanded` project renders one stable `ProjectSidebarBody` keyed by `projectId`, regardless of active status.
+
+### Atomic selection
+
+Do not send `selectProject` followed by an unqualified worktree/session action. VS Code message handlers are asynchronous and are not a transaction queue.
+
+Use one atomic selection intent:
+
+```ts
+interface ActivateSelectionMessage {
+ type: "agentManager.activateSelection"
+ target: SidebarTarget
+}
+```
+
+The coordinator validates the project and target, ensures the context is ready, activates the project-specific Kilo route, then emits one project-qualified activation result. The sidebar body stays mounted; only shared detail state changes.
+
+Mutations that do not need the detail pane, such as rename, delete, section edits, run, setup, open window, PR actions, or worktree creation, execute directly against their explicit `ProjectRef` or `WorktreeRef`. They do not activate another project as a side effect.
+
+## Configuration architecture
+
+See [`agent-manager-multi-project-configuration.md`](./agent-manager-multi-project-configuration.md) for the complete contract.
+
+### Verified blocking failure
+
+The current config protocol is unsafe for multi-project use:
+
+- `KiloProvider.fetchAndSendConfig()` resolves a mutable current directory and emits one unqualified `configLoaded` value (`packages/kilo-vscode/src/KiloProvider.ts:2513`).
+- `ConfigProvider` owns one global/project/effective draft and sends an unqualified `updateConfig` (`packages/kilo-vscode/webview-ui/src/context/config.tsx:54`, `:243`).
+- `KiloProvider.handleUpdateConfig()` resolves `getWorkspaceDirectory()` again at save time (`packages/kilo-vscode/src/KiloProvider.ts:2982`). A draft loaded for project A can therefore be written to project B after detail activation changes.
+- The backend derives the project target from request directory and has no target or revision precondition (`packages/opencode/src/config/config.ts:944`, `packages/opencode/src/kilocode/config/config.ts:67`). Concurrent editors can overwrite one another, and a newly created higher-priority config file can redirect a pending write.
+- Hidden scope splitting currently writes `commit_message` and `indexing.enabled` to the project while most fields go global (`packages/kilo-vscode/webview-ui/src/utils/config-scope.ts:3`). The UI does not make this target choice explicit.
+- Project config writes also exist outside the save bar. In particular, provider disconnect can call `saveProject()` using the mutable provider workspace directory (`packages/kilo-vscode/src/provider-actions.ts:252`). Disabling only `handleUpdateConfig()` is insufficient.
+
+This is a release blocker, not a follow-up cleanup.
+
+### Product decision and staged order
+
+Preserve useful project-local editing such as `commit_message.prompt` and repository indexing rules. Indexing enablement is machine-local project consent, default off, and cannot be granted by repository config. Replace hidden active-directory targeting with explicit `User | Project` scope, a required Settings project selector for Project scope, an opaque immutable binding, and optimistic concurrency by target revision.
+
+Do not maintain one write model for flag-off mode and another for multi-project mode. Single-project mode uses the same explicit protocol, with a narrow adapter that injects the pinned `ProjectRef` for reads and open-file actions.
+
+### Separate runtime config from Settings editor config
+
+The current `ConfigProvider` conflates two different state machines. Split them:
+
+- **Runtime config** is read-only UI state for the selected detail/session route. It is keyed by `{ projectId, directory, activationGeneration }`, follows atomic detail activation, and drives chat, providers, agents, features, display, MCP, indexing, and sandbox behavior.
+- **Settings editor config** is a scoped layer editor keyed by an immutable binding. It never follows detail activation and its draft is never applied optimistically to runtime config.
+
+Use distinct messages. Names may follow repository conventions, but the fields and invariants are mandatory:
+
+```ts
+interface RuntimeConfigLoaded {
+ type: "runtimeConfigLoaded"
+ route: {
+ projectId: ProjectId
+ directory: string
+ activationGeneration: number
+ }
+ config: Config
+ features: FeatureFlags
+}
+
+type ConfigScope = "global" | "project"
+
+interface ConfigTarget {
+ scope: ConfigScope
+ path: string
+ revision: string
+ exists: boolean
+ writable: boolean
+}
+
+interface SettingsBinding {
+ id: string
+ connectionGeneration: number
+ scope: ConfigScope
+ project?: {
+ projectId: ProjectId
+ root: string
+ generation: number
+ }
+ directory: string
+ target: ConfigTarget
+}
+
+interface ReadSettingsConfig {
+ type: "settingsConfig.read"
+ requestId: string
+ scope: ConfigScope
+ projectId?: ProjectId
+}
+
+interface SettingsConfigSnapshot {
+ type: "settingsConfig.snapshot"
+ requestId: string
+ binding: SettingsBinding
+ preview?: {
+ projectId: ProjectId
+ root: string
+ generation: number
+ directory: string
+ }
+ targetConfig: Config
+ effective: Config
+ global: Config
+ project: Config
+ fields: Record
+ collections: Record
+}
+
+interface WriteSettingsConfig {
+ type: "settingsConfig.write"
+ requestId: string
+ bindingId: string
+ set: Partial
+ unset: string[][]
+}
+```
+
+For a global read, `projectId` is optional preview context used only to explain project shadowing. It never changes the global target. A project read requires `projectId` and always resolves the immutable `ProjectContext.root`, not the active worktree or session.
+
+For User scope, preview identity is presentation state, not part of the editable target identity. The controller keeps the global target binding and draft separate from the latest project preview. A clean preview change may replace both from one fresh snapshot. A dirty preview change updates only `preview`, effective/source metadata, and project raw data. It retains the original global target revision; if the fresh read reports a different global path or revision, mark the draft stale instead of silently rebasing it.
+
+`targetConfig` is the parsed raw content of the one file the API will patch. It is not the aggregate `global` or `project` layer. The form edits this raw target value so it never copies values inherited from another file into the target. The aggregate values remain in the snapshot solely for source, inheritance, and effective-preview UI. Existing JSONC comments remain server-side raw text and are preserved by patching; the webview does not round-trip serialized file contents.
+
+Parse `targetConfig` from raw JSONC without expanding `{env:}` or `{file:}` variables, so the Settings protocol never turns placeholders into secret values. If the exact target is syntactically or structurally invalid, return diagnostics and `writable: false` for form editing rather than treating it as `{}` and overwriting it. The recovery action is **Open file**. Source metadata must cover every path rendered by Settings, not only the current hand-maintained overlay field list.
+
+The extension owns an in-memory binding registry. Binding IDs are opaque, unpredictable, and scoped to one provider/webview lifecycle. The webview returns only `bindingId`, not a client-authoritative path or project envelope. On write the extension must:
+
+1. look up `bindingId` and reject unknown or expired bindings;
+2. verify that the project still exists, has the same context generation, and remains trusted;
+3. capture the stored binding before the first `await`;
+4. send the stored directory and expected target to the backend without calling `getWorkspaceDirectory()`, `contexts.active()`, or any fallback;
+5. route success or failure by `{ requestId, binding.id }` only.
+
+A binding expires after a successful save, backend reconnect, trust revocation, project removal, or context generation change. Success returns a new snapshot and binding. Cached snapshots are keyed by binding/scope/project identity; there is no singleton `cachedConfigMessage` for Settings.
+
+### Backend read/write and optimistic concurrency contract
+
+Evolve the Kilo-owned `/config/overlay` API rather than adding project identity to generic `Config.update`. The backend does not know Agent Manager `projectId`; it enforces the routed directory and filesystem target while the extension enforces project identity.
+
+`GET /config/overlay` continues to take explicit `directory` and `scope`, but returns:
+
+```ts
+interface ConfigOverlaySnapshot {
+ context: { directory: string; worktree?: string }
+ scope: ConfigScope
+ targetConfig: Config
+ effective: Config
+ global: Config
+ project: Config
+ sources: ConfigSource[]
+ targets: {
+ global: ConfigTarget
+ project: ConfigTarget
+ active: ConfigTarget
+ }
+ fields: Record
+ collections: Record
+}
+```
+
+The revision is a SHA-256 fingerprint of scope, canonical target path, existence marker, and exact file bytes. It is not an mtime. Exact bytes are required so comment-only JSONC edits and delete/recreate cycles with different content cause conflicts. The missing-file revision includes the intended canonical path. An ABA delete/recreate with identical bytes may retain the same revision; that state is content-equivalent and does not lose a user edit.
+
+`PATCH /config/overlay?directory=...` accepts exactly one scope per request:
+
+```ts
+interface ConfigOverlayWrite {
+ scope: ConfigScope
+ set?: Record
+ unset?: string[][]
+ expected: {
+ path: string
+ revision: string
+ }
+}
+
+interface ConfigOverlayWriteResult {
+ outcome: "applied" | "applied_but_overridden"
+ changed: boolean
+ overriddenPaths: string[][]
+ snapshot: ConfigOverlaySnapshot
+}
+```
+
+The server performs the following transaction:
+
+1. Resolve the authoritative target from the routed instance directory and requested scope. Never accept `expected.path` as an arbitrary destination.
+2. Canonicalize and compare the resolved and expected paths. Reject a changed target, including a newly created higher-priority config file.
+3. Acquire a cross-process lock keyed by canonical target path. Project writes must use the same locking discipline already used for global config.
+4. Inside the lock, re-resolve the target, read exact bytes, recompute the revision, and reject a mismatch.
+5. Apply `set`/`unset` to the raw target layer, preserve JSONC comments where supported, validate the result, and atomically replace the target using a temporary file in the same directory. Do not expose a partially written config.
+6. Invalidate the affected config instances, reload the overlay, and return the authoritative post-write snapshot. Do not fabricate optimistic config if refresh fails.
+7. Emit a config event containing `scope`, routed `directory`, canonical `target`, and new `revision`. Global changes invalidate all runtime config caches; project changes invalidate only matching directory/project caches.
+
+`applied_but_overridden` is success. For each `set` leaf, the refreshed overlay identifies whether a higher layer still wins, such as project config shadowing a user value or managed/runtime config shadowing a project value. The UI keeps the saved value and explains why effective behavior did not change.
+
+Do not send global and project patches in one webview message or pretend that two files save atomically. User and Project scope maintain independent drafts and save independently. VS Code extension preferences such as autocomplete settings are a third, visibly separate store with their own request acknowledgements.
+
+Domain actions outside Settings must not perform client-side read-modify-write against a mutable effective config. Provider/custom-provider/work-style actions are User-global operations and should call either the same revision-aware User endpoint or a narrow backend mutation that merges named fields under the target lock. Permission Dock "always" rules remain a narrow global rule mutation tied to the exact pending request/session route; that session directory validates the request but does not select a project config target. All such mutations emit the revisioned config event, so a dirty Settings editor becomes stale rather than being overwritten.
+
+Expected typed failures are:
+
+- `unknown_project` or `stale_project_generation` from the extension;
+- `untrusted_project` or `workspace_untrusted` from the extension;
+- `binding_mismatch` or `binding_expired` from the extension;
+- HTTP 409 `config_target_changed` with the current target descriptor;
+- HTTP 409 `config_revision_conflict` with the current target descriptor and fresh snapshot when available;
+- HTTP 403 `config_target_not_writable` or project target escaping its trusted root;
+- HTTP 422 `config_invalid` with schema/parse details;
+- an I/O failure with no success acknowledgement.
+
+Every failure preserves the draft and names the scope/project/path. A config event is never treated as confirmation of the user's save; only a matching write response may clear that draft.
+
+### Draft and project-switch behavior
+
+- Detail activation from project A to B does not change the Settings selector, binding, values, draft, save state, or target.
+- A User Settings draft is global and survives all detail-project switches.
+- Project scope uses an explicit trusted project selector that never follows detail activation automatically.
+- On first open, the selector may initialize once from the pinned/selected project for convenience, but that choice is immediately captured as Settings state.
+- Choosing another Settings project with a dirty draft prompts `Save`, `Discard`, or `Stay`. Drafts are never carried to a different project.
+- Clean selector changes issue a new request and ignore out-of-order responses by `requestId`.
+- A save captures one binding. Switching visible settings while it is in flight does not redirect it; the result updates only the original binding store and identifies that target in its notification.
+- External config events refresh a clean binding. For a dirty or saving binding they mark it stale and show a changed-on-disk banner without replacing local edits.
+- A 409 preserves the draft and disables blind retry. The first version offers `Reload`, `Open file`, and `Discard`; it does not auto-merge or force overwrite. A future three-way merge may use the original snapshot, fresh snapshot, and draft.
+
+### Scope UX
+
+The first release presents explicit `User | Project` scope, a required selector in Project scope, source badges, and separate binding-keyed drafts. The selected scope applies to the entire operation. Remove hidden `splitConfigByScope`; no setting silently chooses a different file. Inherited user values remain visible in Project scope with `Override`/`Reset to inherited` affordances backed by `set`/`unset`.
+
+### Project root, worktree, and trust semantics
+
+- A Project Settings binding targets the registered checkout root held by `ProjectContext.root`. It never targets the last selected Local/worktree/session card.
+- Runtime config remains directory-correct: Local uses the project root, and a worktree session uses its exact routed worktree directory. Branch-specific config in a managed worktree can therefore differ at runtime.
+- Editing a project's root config does not claim to update already-created worktree checkouts. Editing a worktree config from Settings is out of scope for the first Project Settings release. A future worktree editor must use an explicit `WorktreeRef` and its own immutable binding.
+- The backend resolver may choose an existing root or `.kilo`/`.kilocode` config according to current precedence, but the expected target must remain within the trusted project checkout after canonical/symlink resolution. A symlink escape is read-only and must not be patched through the API.
+- For an existing target, canonicalize the file with `realpath`. For a missing target, canonicalize its nearest existing ancestor and append the remaining path components before checking containment. Global writes must similarly remain under the canonical `Global.Path.config` root. Do not rely on a lexical prefix check.
+- Additional Agent Manager projects must be registry-trusted before project overlay evaluation, opening/creating project config, or project writes. Global User Settings remain editable without trusting a project.
+- The pinned project is trusted for config evaluation only when the VS Code workspace is trusted. Do not treat `pinned` as an unconditional trust grant.
+- Trust is stored outside the repository in the extension registry/global state. A project config file cannot grant its own trust. Revoking trust invalidates all bindings and project runtime caches.
+
+### Concrete implementation sequence for config safety
+
+1. In `packages/opencode/src/kilocode/config/overlay.ts`, replace path-only targets with revisioned target descriptors and add generic changed-path source resolution.
+2. Add a Kilo-owned compare-and-swap writer under `packages/opencode/src/kilocode/config/` that resolves, confines, locks, fingerprints, patches, validates, and atomically replaces one target. Keep shared `packages/opencode/src/config/config.ts` changes to minimal marked delegation/invalidation hooks.
+3. Update `packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts` and `handlers/config-console.ts` with the write precondition, result, and typed error contracts. Regenerate `packages/sdk/js/`.
+4. Extract a config binding controller under `packages/kilo-vscode/src/kilo-provider/` and have `KiloProvider.ts` delegate reads/writes to it. The controller receives explicit project-route resolution; it never reads mutable active workspace state during a bound operation.
+5. Replace `configLoaded`, `configUpdated`, and unqualified `updateConfig` in `webview-ui/src/types/messages/` with runtime and Settings message families. Keep a temporary single-project adapter only at the protocol edge.
+6. Split `webview-ui/src/context/config.tsx` into activation-bound runtime state and binding-keyed Settings editor state. Settings components consume the editor context; non-settings consumers continue to use runtime-effective config.
+7. Remove `webview-ui/src/utils/config-scope.ts` after migrating every control to explicit scope. Preserve explicit project editing for commit messages and repository indexing rules; move indexing enablement to machine-local project consent.
+8. Audit all extension config mutators and route user-config imports, resets, custom providers, work-style writes, project writes, and save-bar writes through a revision-aware endpoint or backend atomic field-level mutation. A project-sourced item must never fall back to an active directory write.
+9. Make open-file actions take `ProjectRef`, resolve `ProjectContext.root`, verify trust, and show the exact path. For a missing target, open an unsaved document at that explicit path or require an explicit create action; never create it merely by visiting Settings.
+10. Update config events and cache invalidation so scope/directory/revision are retained end to end.
+
+Focused tests belong in:
+
+- `packages/opencode/test/kilocode/server/config-overlay.test.ts` for raw target snapshots, target changes, 409 revisions, source shadowing, locks, symlink confinement, and response outcomes;
+- `packages/opencode/test/kilocode/project-config-update.test.ts` for target selection and atomic writes from repository roots/nested directories;
+- a new Kilo-owned backend unit test beside those files only if the compare-and-swap writer needs direct fault-injection coverage;
+- `packages/kilo-vscode/tests/unit/config-utils.test.ts`, replacing singleton `ConfigState` assumptions with binding/request-aware draft tests;
+- `packages/kilo-vscode/tests/unit/config-scope.test.ts`, deleted when `splitConfigByScope` is removed and replaced by tests proving one explicit scope per save;
+- Agent Manager route/context tests for immutable project-root resolution, trust revocation, removal, and generation changes;
+- KiloProvider protocol tests for A-to-B activation during reads and writes, out-of-order responses, cache partitioning, and auditing non-save-bar mutators.
+
+## One `.kilo/agent-manager.json` per project
+
+Yes, every project has its own repository-local state file:
+
+```text
+project-a/.kilo/agent-manager.json
+project-b/.kilo/agent-manager.json
+project-c/.kilo/agent-manager.json
+```
+
+This is already structurally supported because `WorktreeStateManager` derives its file from its immutable constructor root. The final architecture makes this ownership explicit and safe.
+
+### Ownership rules
+
+Each `ProjectContext` owns exactly one:
+
+- canonical repository root and canonical Git common directory;
+- `WorktreeStateManager(root)` writing only `/.kilo/agent-manager.json`;
+- `WorktreeManager(root)`;
+- `SetupScriptService(root)` and project run service;
+- diff/import/branch-naming services;
+- git stats and PR pollers;
+- worktree/session/section/order/run/stale caches;
+- mutation queue and initialization promise.
+
+The global project registry stores descriptors only. It never stores worktrees, sections, sessions, tab order, or repository behavior. The pinned project is derived from the current workspace and remains outside the registry.
+
+### Initialization
+
+The first explicit expansion of a trusted project calls one single-flight `ensureReady()` promise:
+
+1. validate root and Git common directory;
+2. construct project-owned services;
+3. update that repository's local Git exclude;
+4. load only that repository's `.kilo/agent-manager.json`;
+5. discover and reconcile only that repository's worktrees;
+6. register Local and worktree session routes for that project;
+7. start context-owned polling if the project is expanded and the panel is visible;
+8. post one generation-stamped project snapshot.
+
+Repeated expansion must reuse the same initialization promise. Collapse/removal during initialization records the desired lifecycle transition and prevents late completion from posting or mutating a replacement context.
+
+### Writes and disposal
+
+All mutations for one project execute through that context's serialized mutation queue. An operation captures the context and generation before its first await and never re-resolves through `contexts.active()` afterward.
+
+Collapse:
+
+- leaves the project registered;
+- stops pollers/watchers after current operations reach a safe boundary;
+- flushes its own state file;
+- retains session routes required for already-running backend sessions;
+- never aborts a session merely because the UI collapsed.
+
+Removal:
+
+- never deletes `.kilo/agent-manager.json`, worktrees, branches, or sessions;
+- refuses removal while a project mutation/run requires ownership, unless an explicit coordinated shutdown is implemented;
+- awaits context disposal and state flush before deleting the registry descriptor;
+- invalidates context generation before awaits so late callbacks are ignored.
+
+Panel disposal awaits all initialized contexts before shared services are disposed.
+
+Two VS Code windows writing the same repository state file remain the same pre-existing concurrency boundary as today's single-project Agent Manager. This plan prevents one panel from creating two contexts for the same Git common directory; cross-window file coordination is not expanded in this feature.
+
+## ProjectContext lifecycle
+
+`ProjectContext` becomes a lifecycle owner rather than only a lazy service container.
+
+```ts
+type ProjectLifecycle =
+ | "cold"
+ | "initializing"
+ | "ready"
+ | "suspended"
+ | "disposing"
+ | "disposed"
+
+interface ProjectContext {
+ readonly id: ProjectId
+ readonly root: string
+ readonly commonGitDir: string
+ readonly generation: number
+ ensureReady(): Promise
+ run(operation: (ctx: ReadyProjectContext) => Promise): Promise
+ suspend(): Promise
+ dispose(): Promise
+}
+```
+
+Every async init, poll, mutation, diff, run, import, setup, PR update, branch naming, and session operation checks context identity and generation before committing state or posting to the webview.
+
+## Strict project-qualified protocol
+
+When multi-project mode exposes more than one project, every repository operation requires project identity.
+
+Required project qualification includes:
+
+- worktree create/delete/rename/import/order/section/open/PR actions;
+- Local and worktree session create/open/close/fork/promote/share/unshare actions;
+- prompt, abort, permission, question, command, sandbox, and context actions;
+- terminal create/input/resize/close/show actions;
+- diff/watch/apply/revert actions;
+- run/setup/configuration actions;
+- all state/session/diff/run/terminal/setup/PR output messages.
+
+Missing, unknown, mismatched, inactive-when-required, or ambiguous identities return a typed route error. They never fall back to the current project or `workspaceFolders[0]`.
+
+Single-project mode retains a narrow compatibility adapter that injects the pinned project identity. Core handlers always operate on explicit references.
+
+## One KiloProvider with a project/session route service
+
+Keep one embedded `KiloProvider`. Multiple providers would duplicate event handling, terminal registrations, caches, and current-session state for one webview.
+
+Add an Agent Manager route service:
+
+```ts
+interface SessionRoute extends SessionRef {
+ directory: string
+ generation: number
+}
+```
+
+It must:
+
+- register explicit routes for every Local session at its project root;
+- register every worktree session at its worktree path;
+- let child sessions inherit the parent route;
+- validate directory ownership;
+- detect ambiguous raw session IDs;
+- supply the exact directory to every SDK operation, including share/unshare;
+- remove current-root and workspace-root fallback for identified Agent Manager sessions;
+- refresh sessions independently for every initialized project and emit real project-stamped `SessionInfo` records.
+
+The shared detail pane consumes the selected `SessionRef`. Every sidebar body consumes its project's real session store. Background session UI may initially be visually simpler, but it must not display fabricated titles or placeholder session objects.
+
+Project activation in `KiloProvider` must invalidate and refresh project-scoped config, providers, agents, commands, skills, MCP state, indexing, sandbox state, and Git status under an activation generation guard.
+
+## Global services with composite keys
+
+Some services remain panel-global to avoid duplicate VS Code registrations, but all associations use composite references.
+
+- terminal mappings: `ProjectId + SessionId` or `ProjectId + WorktreeId`;
+- run state: keyed by `WorktreeRef`, including project Local;
+- visible/presence state: keyed by `SessionRef`;
+- tabs and sidebar selection: keyed by `SidebarTarget`;
+- diff/apply/revert state: keyed by `ProjectRef` and target;
+- pending setup/create/delete state: keyed by project-qualified resource refs.
+
+Remove unknown-worktree-to-current-root terminal fallback. A context supplies a validated CWD to the global terminal manager.
+
+Global backend events are routed once by event directory and registered session route. Status, tool, orchestration, permission, and question events retain their directory and enter exactly one owning project context. A cold/collapsed context is not initialized by an unsolicited event.
+
+## Uniform project sidebar UI
+
+### One body implementation
+
+Extract the existing real `renderBody()` from `AgentManagerApp.tsx` into `ProjectSidebarBody`. Do not maintain active and background implementations.
+
+`ProjectSidebarBody` receives a stable project-scoped store and project-qualified action callbacks. It reuses:
+
+- the existing Local card markup;
+- `WorktreeSectionActions`;
+- `WorktreeItem`;
+- `SectionHeader` and current drag/drop behavior;
+- `UnassignedSessionsSection`;
+- existing rename/delete/open/PR/run/setup/session affordances.
+
+Render it for every expanded project:
+
+```tsx
+
+ {(project) => (
+
+
+
+
+
+ )}
+
+```
+
+The body is keyed by `projectId`, not active status. Changing detail selection cannot remount it.
+
+### Per-project webview stores
+
+Replace active-only shared sidebar signals with `Map` or an equivalent Solid store registry.
+
+Each store owns:
+
+- state/worktrees/sections/order/stale state;
+- real `SessionInfo` and managed-session state;
+- git stats and local stats;
+- PR and run/setup state;
+- pending delete/rename/create state;
+- sidebar search/order/collapse state where repository-owned;
+- generation for dropping stale output.
+
+Only shared detail/chat/terminal state remains global. The top toolbar reads the selected detail target's project store.
+
+`project-live.ts` may be evolved into this complete store registry. It must stop mirroring active payloads into a separate sidebar signal set.
+
+### Scrolling and layout
+
+The project list becomes the sidebar scroll container. Individual project bodies must not use a global `50vh` cap that makes two projects fight for viewport height. Section and project collapse state controls density; normal browser scrolling exposes all expanded worktrees.
+
+### UI behavior
+
+- Clicking a Local/worktree/session card emits one atomic project-qualified selection.
+- Rename/delete/run/open/section/PR actions execute against the clicked card's project without changing sidebar structure.
+- The active project may receive an emphasis style, but no different markup.
+- Active project chevrons are not disabled; expansion and detail selection are separate concepts.
+- Global New Session/Run buttons target the last selected detail project.
+- New Worktree accepts an explicit target project, defaulting to the selected detail project.
+
+## Uniform polling and live state
+
+Every ready expanded context owns the same poller set. There is no active singleton poller plus background poller split.
+
+Pollers emit `{ projectId, generation }` and update only their owning context/store. Stop/suspend increments generation before clearing timers so in-flight Git/PR results are dropped.
+
+Context state is pushed after every project mutation, not only initial expansion or active-project changes. This includes worktree/session/section/order/run/setup changes in background projects.
+
+Panel visibility changes poll cadence uniformly across all expanded contexts.
+
+## Registry correctness
+
+Registry mutations use one in-process queue. Each mutation re-reads persisted storage, validates it, applies one mutation, writes once, then updates memory. Trust/remove persistence failures are user-visible and do not leave UI state ahead of storage.
+
+Registry descriptors include canonical root, canonical Git common directory, URI scheme/authority, order, label, trust, and added time. They do not contain Agent Manager repository state.
+
+## Implementation order
+
+### Slice 0: configuration safety blocker
+
+1. Introduce revisioned `/config/overlay` reads and compare-and-swap writes, first for User scope and with the same generic implementation covered for Project scope.
+2. Separate runtime-effective config messages/state from immutable Settings bindings and drafts.
+3. Add explicit `User | Project` Settings scope and an immutable trusted project selector/binding for Project writes.
+4. Remove hidden `splitConfigByScope` persistence and audit every direct/indirect config mutation, including provider disconnect, import/reset, custom providers, work-style presets, Permission Dock, and indexing.
+5. Make config events scope/directory/target/revision aware and generation-guard runtime activation refreshes.
+6. Add target-switch, stale-binding, revision-conflict, trust, and draft-preservation tests.
+
+Do not enable multi-project broadly before this slice passes.
+
+### Slice 1: routing and identity blockers
+
+1. Add project-qualified refs and strict protocol types.
+2. Add the project/session route service and explicit Local routes.
+3. Remove Agent Manager current/workspace-root fallback for identified operations.
+4. Add atomic project-qualified selection.
+5. Convert panel-global maps to composite keys.
+6. Add strict routing and raw-ID collision tests.
+
+Do not expose interactive background controls before this slice passes.
+
+### Slice 2: context lifecycle and service ownership
+
+1. Add single-flight initialization, lifecycle state, generation, and mutation queue to `ProjectContext`.
+2. Move root-bound diff/import/run/setup/branch-naming/poller ownership behind context APIs.
+3. Replace dynamic active getters inside async mutations with captured contexts.
+4. Add suspend/remove/dispose coordination.
+5. Reject duplicate Git common directories and serialize registry writes.
+6. Route global backend events by directory.
+
+### Slice 3: complete per-project stores
+
+1. Refresh and store real sessions per project.
+2. Emit all relevant output with project and generation.
+3. Evolve `project-live.ts` into a complete project sidebar store registry.
+4. Make polling uniform and generation-safe.
+5. Add background mutation/state propagation tests.
+
+### Slice 4: uniform real UI
+
+1. Extract the current `renderBody()` intact into `ProjectSidebarBody`.
+2. Parameterize it by one project store and project-qualified callbacks.
+3. Render one body per expanded project.
+4. Delete `ProjectSummary` and its CSS.
+5. Separate project expansion from detail activation.
+6. Add project-aware New Worktree targeting.
+7. Add component/visual tests proving no body remount or structural change on selection.
+
+### Slice 5: validation and release gating
+
+1. Run focused lifecycle/router/context tests after each slice.
+2. From `packages/opencode/`, run the focused config overlay/project-update tests and CLI typecheck. Run the opencode annotation and Promise-facade guards for shared-file hooks.
+3. Regenerate the JS SDK after endpoint schema changes and verify generated consumers compile; never hand-edit generated sources.
+4. From `packages/kilo-vscode/`, run typecheck, lint, focused/unit tests, knip, compile/package, and architecture guards.
+5. Manually exercise two repositories with multiple worktrees and sessions in both, including a dirty User draft during project activation, an external config edit conflict, project trust revocation, project removal, and project-config open targeting.
+6. Keep the feature flag default off until all acceptance criteria pass.
+
+## Blocking tests
+
+The feature is not complete until tests prove:
+
+1. Two concurrent expands initialize one context once.
+2. Collapse/remove during initialization produces no late state or poller posts.
+3. A project switch during create/import/setup/session/run/diff cannot change operation ownership.
+4. Project B Local sessions never resolve through project A.
+5. Missing/mismatched project references fail closed.
+6. Poller/PR late results after stop or generation change are dropped.
+7. Same raw worktree/session/local IDs in two projects do not collide in runtime or UI stores.
+8. Two expanded projects render two permanent full `ProjectSidebarBody` instances.
+9. Selecting a card changes only shared detail state and active emphasis, not body structure or mount identity.
+10. Every worktree/session/terminal/diff/run/setup/section/PR action targets the clicked project's context.
+11. Registry writes preserve concurrent add/remove/trust changes.
+12. Roots sharing one Git common directory cannot both register.
+13. Panel shutdown awaits all contexts and stops background activity.
+14. Flag-off behavior remains the current single-project UI and state file.
+15. A Settings draft loaded for project A cannot write project B after any activation, expansion, removal, trust, or worktree switch.
+16. User Settings writes target the revisioned user file; explicit Project Settings writes target only the selected trusted project's revisioned root config. No key is silently rerouted.
+17. A clean external config change refreshes Settings; a dirty or saving binding is marked stale without losing its draft.
+18. Concurrent editors using the same revision produce one success and one 409 conflict, with no lost update or partial file.
+19. A comment-only JSONC edit, file deletion/recreation with changed bytes, or newly created higher-priority config changes the revision/target and rejects the stale write.
+20. A save response clears only the matching `{ requestId, binding.id }`; an SSE config event or out-of-order read cannot confirm a save.
+21. Global and project writes are independently acknowledged and never presented as one atomic transaction.
+22. Project config open/read/write actions reject unknown, untrusted, removed, generation-stale, symlink-escaping, or mismatched project targets.
+23. Runtime config for a selected managed worktree uses that worktree directory, while Project Settings/open-file targets the immutable registered project root.
+24. Project-sourced providers and other config collections cannot trigger an implicit active-project write from Settings actions.
+
+## Acceptance criteria
+
+- No `ProjectSummary` or alternate fake-card body exists.
+- Every expanded project displays real live Local, worktree, and session UI concurrently.
+- Every visible worktree/session action is usable and routes by explicit project identity.
+- Switching detail selection causes no sidebar body remount, replacement, collapse, or data reset.
+- Each project reads and writes only its own `.kilo/agent-manager.json`.
+- Expanded projects remain live independently; collapsed projects suspend safely.
+- No identified operation falls back to active/current/workspace root.
+- Single-project behavior and persisted state remain backward-compatible when the flag is off.
+- Shared Settings exposes explicit User and Project scope; Project writes require a trusted project selector and immutable binding.
+- Runtime project activation and Settings editor binding are separate state machines.
+- Every Settings config write has an immutable scope/target binding and revision precondition; narrow domain mutations merge named fields under the same target lock. No config mutation uses a save-time active-directory lookup.
+- Dirty drafts survive external updates and conflicts and can never migrate between projects.
+- Project Settings targets the explicit registered root, never the active Agent Manager project or active worktree.
+
+## Planned UI
+
+```text
+┌─ Sidebar ─────────────────────────────────┬─ Shared detail pane ──────────┐
+│ ← selected project search calendar gear │ │
+│ │ Chat / diff / terminal │
+│ PROJECTS + │ for the selected │
+│ ▾ abalone-bactrosaurus │ project/worktree/session │
+│ ┌─────────────────────────────────┐ │ │
+│ │ Local main +3295 -548 ↓54 │ │ │
+│ └─────────────────────────────────┘ │ │
+│ WORKTREES actions │ │
+│ ┌─────────────────────────────────┐ │ │
+│ │ feature-x #1234 +120 -8 ↓2 │ │ │
+│ │ real menus, rename, run, open │ │ │
+│ └─────────────────────────────────┘ │ │
+│ ┌─────────────────────────────────┐ │ │
+│ │ bugfix-y +45 -12 │ │ │
+│ └─────────────────────────────────┘ │ │
+│ SESSIONS │ │
+│ • Fix login redirect running │ │
+│ • Refactor auth │ │
+│ │ │
+│ ▾ kilo-pi-provider │ │
+│ ┌─────────────────────────────────┐ │ │
+│ │ Local master +16 -6 ↓1 │ │ │
+│ └─────────────────────────────────┘ │ │
+│ WORKTREES actions │ │
+│ ┌─────────────────────────────────┐ │ │
+│ │ provider-z #88 +88 -3 │ │ │
+│ │ real menus, rename, run, open │ │ │
+│ └─────────────────────────────────┘ │ │
+│ SESSIONS │ │
+│ • Add provider tests │ │
+│ │ │
+│ ▸ docs-repo collapsed │ │
+│ │ │
+│ [New Session] [Run] │ │
+└───────────────────────────────────────────┴──────────────────────────────┘
+
+Selecting any card:
+
+- keeps every project body mounted and pixel-stable;
+- changes only the selected styling, top toolbar target, and shared detail pane;
+- never swaps a real body for a summary or vice versa.
+```
diff --git a/packages/kilo-vscode/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs
index d19df68b45..8aedaac465 100644
--- a/packages/kilo-vscode/eslint.config.mjs
+++ b/packages/kilo-vscode/eslint.config.mjs
@@ -43,17 +43,10 @@ export default [
},
{
files: ["webview-ui/agent-manager/AgentManagerApp.tsx"],
- // Raised from 3100 → 3200 for the experimental terminal tabs feature.
- // ~600 lines of terminal logic were extracted to ./terminal/* and
- // ./tab-rendering.tsx; the remaining ~75 lines are signal bindings,
- // a stacking-container wrapper required by the hydration invariant
- // (canvases must never leave the paint tree — see render.tsx), and
- // render-call wiring that must live at the top of
- // `AgentManagerContent` alongside the existing selection/session state.
- // Raised from 3200 → 3210 for the per-message feedback `FeedbackProvider`
- // wiring, which sits inside the provider chain and cannot be extracted
- // without adding an intermediate wrapper component.
- rules: { complexity: ["error", 74], "max-lines": ["error", 3210] },
+ // Lowered 3210 → 2800 after extracting the sidebar body (SidebarBody.tsx)
+ // and the tab bar (TabBar.tsx) into components. Keep shrinking as more
+ // logic moves out; do not raise.
+ rules: { complexity: ["error", 74], "max-lines": ["error", 2800] },
},
{
files: ["src/agent-manager/AgentManagerProvider.ts"],
diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json
index 403c574dca..c6b900f350 100644
--- a/packages/kilo-vscode/package.json
+++ b/packages/kilo-vscode/package.json
@@ -1016,6 +1016,12 @@
"scope": "application",
"description": "Prefix for automatically named Agent Manager branches, for example 'marius/' or 'feature/'. Explicit branch names are unchanged."
},
+ "kilo-code.new.experimental.multiProject": {
+ "type": "boolean",
+ "default": false,
+ "scope": "application",
+ "description": "Allow Agent Manager to manage sessions and worktrees across multiple repositories as projects. The current workspace repository is always the default project; additional projects can be added and persist across restarts."
+ },
"kilo-code.new.indexing.showButtonWhenDisabled": {
"type": "boolean",
"default": true,
diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts
index 943b5dba3b..b33b89998f 100644
--- a/packages/kilo-vscode/src/KiloProvider.ts
+++ b/packages/kilo-vscode/src/KiloProvider.ts
@@ -1,4 +1,5 @@
import * as path from "path"
+import { existsSync } from "fs"
import * as vscode from "vscode"
import type {
KiloClient,
@@ -163,6 +164,8 @@ import type { Agent } from "@kilocode/sdk/v2/client"
import { configFeatures } from "./features"
import { createAutoApproveBridge } from "./kilo-provider/auto-approve"
import type { KiloProviderOptions } from "./kilo-provider/options"
+import type { ProjectRef, SessionRef, WorktreeRef } from "./agent-manager/project-route"
+import { indexingConsentStore, registeredProjects } from "./indexing-consent"
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
import { fetchImageModels } from "./image-generation/models"
import { stopSessionProcesses } from "./kilo-provider/background-process"
@@ -172,7 +175,14 @@ import {
validIndexingSetting,
watchIndexingConfig,
} from "./kilo-provider/indexing-settings"
-import { buildChatSettingsMessage, validChatSetting, watchChatConfig } from "./kilo-provider/chat-settings"
+import {
+ ConfigBindings,
+ type ConfigBinding,
+ type ConfigProject,
+ type ConfigTarget,
+} from "./kilo-provider/config-bindings"
+import { canonicalizePath, projectIdFor, samePath } from "./agent-manager/project-paths"
+import { validChatSetting, watchChatConfig } from "./kilo-provider/chat-settings"
import { buildThroughputSettingMessage, watchThroughputConfig } from "./kilo-provider/throughput-settings"
let maxCost = 0
@@ -189,6 +199,10 @@ type SandboxSupportClient = {
options: { throwOnError: true },
) => Promise<{ data: { available: boolean; reason?: string } }>
}
+type ConfigSnapshot = {
+ effective: Config
+ targets: { global: ConfigTarget; project: ConfigTarget }
+}
function sandboxClient(client: KiloClient | null) {
const sandbox = client?.sandbox
@@ -331,6 +345,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private cachedCommandsMessage: unknown = null
/** Cached configLoaded payload so requestConfig can be served before client is ready */
private cachedConfigMessage: unknown = null
+ private readonly configBindings = new ConfigBindings()
private cachedGlobalConfig: Config | null = null
/** Cached indexingStatusLoaded payload so requestIndexingStatus can be served before client is ready */
private cachedIndexingStatusMessage: unknown = null
@@ -412,6 +427,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private ignoreControllerDir: string | null = null
private chatAutocomplete: ChatTextAreaAutocomplete | null = null
private projectDirectory: string | null | undefined
+ private indexingProjectId: string | undefined
+ private indexingSettingsRequest = 0
+ private indexingStatusRequest = 0
private slimEditMetadata = true
private pendingFollowup: Followup | null = null
@@ -536,7 +554,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
public setProjectDirectory(directory: string | null): void {
if (this.projectDirectory === directory) return
this.projectDirectory = directory
+ this.configBindings.clear()
+ this.cachedConfigMessage = null
this.postMessage({ type: "workspaceDirectoryChanged", directory: directory ?? "" })
+ this.postMessage({ type: "configBindingExpired", reason: "project-changed" })
this.requirements.clear()
}
@@ -799,14 +820,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* When set, all operations for this session use this directory instead of the workspace root.
*/
public setSessionDirectory(sessionId: string, directory: string): void {
- this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId))
+ const current = this.sessionDirectories.get(sessionId) ?? this.getRootDirectory()
+ this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), current)
this.sessionDirectories.set(sessionId, directory)
this.requirements.clear()
if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId)
}
public clearSessionDirectory(sessionId: string): void {
- this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId))
+ const current = this.sessionDirectories.get(sessionId) ?? this.getRootDirectory()
+ this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), current)
this.sessionDirectories.delete(sessionId)
this.requirements.clear()
if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId)
@@ -817,11 +840,53 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return this.sessionDirectories
}
+ /** Register a project root with the shared Agent Manager route service. */
+ public registerProjectRoute(ref: ProjectRef, root: string, generation: number): void {
+ this.opts.routeService?.registerProject(ref.projectId, root, generation)
+ }
+
+ /** Drop a project and all its session/worktree routes from the route service. */
+ public unregisterProjectRoute(projectId: string): void {
+ this.opts.routeService?.unregisterProject(projectId)
+ }
+
+ /** Register a worktree directory under a project. */
+ public registerWorktreeRoute(ref: WorktreeRef, directory: string, generation: number): void {
+ this.opts.routeService?.registerWorktree(ref, directory, generation)
+ }
+
+ /** Register a session directory under a project (exact routing). */
+ public registerSessionRoute(ref: SessionRef, directory: string, generation: number): void {
+ this.opts.routeService?.registerSession(ref, directory, generation)
+ }
+
+ /** Drop one session route, keeping the raw ambiguity index consistent. */
+ public unregisterSessionRoute(ref: SessionRef): void {
+ this.opts.routeService?.unregisterSession(ref)
+ }
+
+ /** Whether a raw session id is known to be ambiguous across projects. */
+ public isSessionRouteAmbiguous(sessionId: string): boolean {
+ return this.opts.routeService?.isSessionAmbiguous(sessionId) ?? false
+ }
+
+ /** Exact directory for a project-qualified session ref, or undefined. */
+ public routeSessionDirectoryFor(ref: SessionRef): string | undefined {
+ return this.opts.routeService?.trySessionDirectoryFor(ref)
+ }
+
public async getSessionInfo(sessionId: string): Promise {
await this.initializeConnection()
const client = this.client
if (!client) return
- const directory = this.getWorkspaceDirectory(sessionId)
+ // Agent Manager: never query an ambiguous raw session id against the
+ // active root. The same id may exist in two projects, and the
+ // sessionDirectories map can only hold one entry per raw id, so falling
+ // back to the active root would silently resolve the wrong project's
+ // session. Require an exact route (qualified or unambiguous) instead.
+ const routed = this.routeSessionDirectory(sessionId)
+ if (routed === null) return undefined
+ const directory = routed ?? this.getWorkspaceDirectory(sessionId)
return retry(() => client.session.get({ sessionID: sessionId, directory }, { throwOnError: true }))
.then((result) => result.data)
.catch((error: unknown) => {
@@ -927,7 +992,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.autocompleteConfigDisposable?.dispose()
this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg))
this.indexingConfigDisposable?.dispose()
- this.indexingConfigDisposable = watchIndexingConfig((msg) => this.postMessage(msg))
+ this.indexingConfigDisposable = watchIndexingConfig(() => void this.sendIndexingSettings())
this.chatConfigDisposable?.dispose()
this.chatConfigDisposable = watchChatConfig((msg) => this.postMessage(msg))
this.throughputConfigDisposable?.dispose()
@@ -951,6 +1016,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
connection: this.connectionService,
dir: this.getWorkspaceDirectory(this.currentSession?.id),
post: (msg) => this.postMessage(msg),
+ browserSettings: () => this.sendBrowserSettings(),
exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID),
openSessions: (ids) => this.trackOpenSessions(ids),
})
@@ -1270,11 +1336,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
console.error("[Kilo New] fetchAndSendIndexingStatus failed:", e),
)
break
- case "requestIndexingSettings":
- this.postMessage(buildIndexingSettingsMessage())
+ case "requestIndexingSettings": {
+ const project = await this.sendIndexingSettings(message.projectId)
+ if (message.projectId && project) await this.fetchAndSendIndexingStatus(project.root, project.id)
break
- case "requestChatSettings":
- this.postMessage(buildChatSettingsMessage())
+ }
+ case "setIndexingConsent":
+ await this.setIndexingConsent(message.projectId, message.enabled)
break
case "requestKiloEmbeddingModels":
this.fetchAndSendKiloEmbeddingModels().catch((e) =>
@@ -1290,6 +1358,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
message.projectConfig,
message.globalUnset,
message.projectUnset,
+ message.globalBindingId,
+ message.projectBindingId,
)
break
case "openSettingsTab":
@@ -1344,9 +1414,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "updateSetting":
await this.handleUpdateSetting(message.key, message.value)
break
- case "requestBrowserSettings":
- this.sendBrowserSettings()
- break
case "requestClaudeCompatSetting":
this.sendClaudeCompatSetting()
break
@@ -1359,9 +1426,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "requestTimelineSetting":
this.sendTimelineSetting()
break
- case "requestThroughputSetting":
- this.postMessage(buildThroughputSettingMessage())
- break
case "requestNotifications":
this.fetchAndSendNotifications().catch((e) =>
console.error("[Kilo New] fetchAndSendNotifications failed:", e),
@@ -1571,6 +1635,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.connectionState = "connecting"
this.connectionGeneration++
+ this.configBindings.clear()
this.postMessage({ type: "connectionState", state: "connecting" })
// Clean up any existing subscriptions (e.g., sidebar re-shown)
@@ -1585,7 +1650,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.unsubscribeDirectoryProvider?.()
try {
- const workspaceDir = this.getWorkspaceDirectory()
+ const workspaceDir = this.settingsDirectory()
// Connect the shared service (no-op if already connected)
await this.connectionService.connect(workspaceDir)
@@ -1636,7 +1701,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Subscribe to connection state changes
this.unsubscribeState = this.connectionService.onStateChange(async (state, error) => {
- if (this.connectionState !== state) this.connectionGeneration++
+ if (this.connectionState !== state) {
+ this.connectionGeneration++
+ this.configBindings.clear()
+ }
this.connectionState = state
this.postConnectionState(error)
@@ -2561,20 +2629,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
try {
- const workspaceDir = this.getWorkspaceDirectory()
+ const workspaceDir = this.settingsDirectory()
const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([
retry(() => this.client!.config.get({ directory: workspaceDir }, { throwOnError: true })),
this.client.global.config.get({ throwOnError: true }),
this.client.config.overlay({ directory: workspaceDir, scope: "project" }, { throwOnError: true }),
])
this.cachedGlobalConfig = global ?? null
+ const bindings = this.bindingsFor(workspaceDir, overlay?.targets)
const message = {
type: "configLoaded",
config,
- globalConfig: global,
- projectConfig: overlay?.project,
- settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() },
+ globalConfig: overlay?.targets?.global.raw ?? global,
+ projectConfig: bindings.project ? overlay?.targets?.project.raw : undefined,
+ bindings,
+ settings: this.configSettings(),
features: configFeatures(config),
}
this.cachedConfigMessage = message
@@ -2596,8 +2666,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
- private async fetchAndSendIndexingStatus(): Promise {
- if (!this.client) {
+ private async fetchAndSendIndexingStatus(directory?: string, projectId?: string): Promise {
+ if (!this.client || !this.extensionContext) {
if (this.cachedIndexingStatusMessage) {
this.postMessage(this.cachedIndexingStatusMessage)
}
@@ -2606,21 +2676,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const config = this.connectionService.getServerConfig()
if (!config) return
+ const request = ++this.indexingStatusRequest
try {
- const dir = this.getWorkspaceDirectory(this.currentSession?.id)
- const auth = Buffer.from(`kilo:${config.password}`).toString("base64")
- const res = await fetch(`${config.baseUrl}/indexing/status`, {
- headers: {
- Authorization: `Basic ${auth}`,
- ...(dir ? { "x-kilo-directory": dir } : {}),
- },
- })
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
- const status = (await res.json()) as IndexingStatus
+ const dir = directory ?? this.getWorkspaceDirectory(this.currentSession?.id)
+ if (!dir) return
+ const store = indexingConsentStore(this.extensionContext)
+ const project = await store.project(dir)
+ const status = await this.syncIndexingConsent(project.root, store.enabled(project.id), config)
+ if (request !== this.indexingStatusRequest) return
const message = {
type: "indexingStatusLoaded",
status,
+ projectId,
}
this.cachedIndexingStatusMessage = message
this.postMessage(message)
@@ -2672,27 +2740,30 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async fetchAndSendConfigUpdated(): Promise {
if (!this.client || this.connectionState !== "connected") return
try {
- const dir = this.getWorkspaceDirectory()
+ const dir = this.settingsDirectory()
const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([
retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })),
this.client.global.config.get({ throwOnError: true }),
this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }),
])
this.cachedGlobalConfig = global ?? null
+ const bindings = this.bindingsFor(dir, overlay?.targets)
this.cachedConfigMessage = {
type: "configLoaded",
config,
- globalConfig: global,
- projectConfig: overlay?.project,
- settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() },
+ globalConfig: overlay?.targets?.global.raw ?? global,
+ projectConfig: bindings.project ? overlay?.targets?.project.raw : undefined,
+ bindings,
+ settings: this.configSettings(),
features: configFeatures(config),
}
this.postMessage({
type: "configUpdated",
config,
- globalConfig: global,
- projectConfig: overlay?.project,
- settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() },
+ globalConfig: overlay?.targets?.global.raw ?? global,
+ projectConfig: bindings.project ? overlay?.targets?.project.raw : undefined,
+ bindings,
+ settings: this.configSettings(),
features: configFeatures(config),
})
} catch (error) {
@@ -3020,6 +3091,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
project: Partial = {},
globalUnset: string[][] = [],
projectUnset: string[][] = [],
+ globalBindingId?: string,
+ projectBindingId?: string,
): Promise {
if (!this.client || this.connectionState !== "connected") {
this.postMessage({ type: "configUpdateFailed", message: "Not connected to CLI backend" })
@@ -3038,100 +3111,160 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
project.agent !== undefined
const hasGlobal = Object.keys(partial).length > 0 || globalUnset.length > 0
const hasProject = Object.keys(project).length > 0 || projectUnset.length > 0
+ if (!hasGlobal && !hasProject) return
+
+ const globalBinding = hasGlobal
+ ? this.configBindings.get(globalBindingId, this.connectionGeneration, (project) =>
+ this.validConfigProject(project),
+ )
+ : undefined
+ const projectBinding = hasProject
+ ? this.configBindings.get(projectBindingId, this.connectionGeneration, (project) =>
+ this.validConfigProject(project),
+ )
+ : undefined
+ if ((hasGlobal && !globalBinding) || (hasProject && !projectBinding)) {
+ this.postMessage({ type: "configUpdateFailed", message: "Settings changed or expired. Reload before saving." })
+ return
+ }
this.pending++
- const dir = this.getWorkspaceDirectory()
+ const dir = projectBinding?.directory ?? globalBinding?.directory ?? this.settingsDirectory()
+ const completed: Array<"global" | "project"> = []
+ let snapshot: ConfigSnapshot | undefined
try {
await this.connectionService.drainPendingPrompts()
if (hasGlobal) {
- await this.client.config.overlayUpdate(
- { scope: "global", set: partial, unset: globalUnset, directory: dir },
+ const result = await this.client.config.overlayUpdate(
+ {
+ scope: "global",
+ set: partial,
+ unset: globalUnset,
+ directory: globalBinding!.directory,
+ expected: {
+ path: globalBinding!.target.path,
+ revision: globalBinding!.target.revision,
+ },
+ },
{ throwOnError: true },
)
+ snapshot = result.data as ConfigSnapshot
+ completed.push("global")
}
if (hasProject) {
- await this.client.config.overlayUpdate(
- { scope: "project", set: project, unset: projectUnset, directory: dir },
+ const result = await this.client.config.overlayUpdate(
+ {
+ scope: "project",
+ set: project,
+ unset: projectUnset,
+ directory: projectBinding!.directory,
+ expected: {
+ path: projectBinding!.target.path,
+ revision: projectBinding!.target.revision,
+ },
+ },
{ throwOnError: true },
)
+ snapshot = result.data as ConfigSnapshot
+ completed.push("project")
}
} catch (error) {
- this.postConfigFailure(error)
+ if (completed.length > 0) {
+ if (globalBinding) this.configBindings.consume(globalBinding.id)
+ if (projectBinding) this.configBindings.consume(projectBinding.id)
+ }
+ this.postConfigFailure(error, completed, snapshot, dir)
this.pending--
return
}
+ if (globalBinding) this.configBindings.consume(globalBinding.id)
+ if (projectBinding) this.configBindings.consume(projectBinding.id)
try {
- const [{ data: merged }, { data: global }, { data: overlay }] = await Promise.all([
- retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })),
- this.client.global.config.get({ throwOnError: true }),
- this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }),
- ])
- this.cachedGlobalConfig = global ?? null
+ if (!snapshot) throw new Error("Config update returned no authoritative snapshot")
+ const bindings = this.bindingsFor(dir, snapshot.targets)
+ const global = snapshot.targets.global.raw as Config
+ const projectConfig = bindings.project ? (snapshot.targets.project.raw as Config) : undefined
+ this.cachedGlobalConfig = global
this.cachedConfigMessage = {
type: "configLoaded",
- config: merged,
+ config: snapshot.effective,
globalConfig: global,
- projectConfig: overlay?.project,
- settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() },
- features: configFeatures(merged),
+ projectConfig,
+ bindings,
+ settings: this.configSettings(),
+ features: configFeatures(snapshot.effective),
}
this.postMessage({
type: "configUpdated",
- config: merged,
+ config: snapshot.effective,
globalConfig: global,
- projectConfig: overlay?.project,
- settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() },
- features: configFeatures(merged),
+ projectConfig,
+ bindings,
+ settings: this.configSettings(),
+ features: configFeatures(snapshot.effective),
})
this.requirements.clear()
await Promise.all([
refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(),
refreshAgents ? this.fetchAndSendAgents() : Promise.resolve(),
- ])
+ ]).catch((error) => console.error("[Kilo New] KiloProvider: Post-config refresh failed:", error))
} catch (error) {
- console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error)
- const patch =
- partial.indexing === undefined && project.indexing === undefined
- ? { ...partial, ...project }
- : { ...partial, ...project, indexing: { ...(partial.indexing ?? {}), ...(project.indexing ?? {}) } }
- const cached = (this.cachedConfigMessage as { config?: unknown } | null)?.config
- const features = (this.cachedConfigMessage as { features?: unknown } | null)?.features
- const optimistic =
- cached && typeof cached === "object" ? { ...(cached as Record), ...patch } : patch
- this.postMessage({
- type: "configUpdated",
- config: optimistic,
- globalConfig: this.cachedGlobalConfig ?? undefined,
- settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() },
- features: features ?? configFeatures(optimistic as Config),
- })
- this.requirements.clear()
+ this.postConfigFailure(error, completed, snapshot, dir)
} finally {
this.pending--
}
}
- private postConfigFailure(error: unknown): void {
+ private postConfigFailure(
+ error: unknown,
+ completed: Array<"global" | "project"> = [],
+ snapshot?: ConfigSnapshot,
+ directory?: string,
+ ): void {
console.error("[Kilo New] KiloProvider: Failed to update config:", error)
+ const bindings = snapshot && directory ? this.bindingsFor(directory, snapshot.targets) : undefined
this.postMessage({
type: "configUpdateFailed",
message: getErrorMessage(error) || "Failed to update config",
details: getConfigErrorDetails(error),
+ completedScopes: completed,
+ config: snapshot?.effective,
+ globalConfig: snapshot?.targets.global.raw,
+ projectConfig: bindings?.project ? snapshot?.targets.project.raw : undefined,
+ bindings,
})
}
private async resolveSession(sessionID?: string, draftID?: string, context?: string, contextDirectory?: string) {
if (!this.client) return undefined
- const dir = resolveNewSessionDirectory({
- sessionID,
- currentSessionID: this.currentSession?.id,
- contextSessionID: this.contextSessionID,
- agentManagerContext: context,
- contextDirectory,
- sessionDirectories: this.sessionDirectories,
- workspaceDirectory: this.getRootDirectory(),
- })
+ // Agent Manager: consult the shared route service for an exact directory
+ // before the legacy sessionDirectories/workspace-root resolution. When a
+ // raw session id is ambiguous across projects, refuse to resolve rather
+ // than fall back to the active root or a stale single-entry map — share,
+ // unshare, send, and other command paths route through here. A project
+ // qualifier (when configured) disambiguates to the exact directory.
+ let routedDir: string | null | undefined = undefined
+ if (sessionID) {
+ routedDir = this.routeSessionDirectory(sessionID)
+ if (routedDir === null) {
+ throw new Error(
+ `Session ${sessionID} is ambiguous across projects; cannot resolve a directory without a project qualifier.`,
+ )
+ }
+ }
+
+ const dir =
+ routedDir ??
+ resolveNewSessionDirectory({
+ sessionID,
+ currentSessionID: this.currentSession?.id,
+ contextSessionID: this.contextSessionID,
+ agentManagerContext: context,
+ contextDirectory,
+ sessionDirectories: this.sessionDirectories,
+ workspaceDirectory: this.getRootDirectory(),
+ })
const key = `${draftID ?? context ?? "new"}\0${dir}`
const now = Date.now()
@@ -3275,6 +3408,134 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return vscode.workspace.getConfiguration("kilo-code.new").get("languageCommitMessage", "sync")
}
+ private multiProjectSetting(): boolean {
+ return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("multiProject", false)
+ }
+
+ private async sendIndexingSettings(projectId?: string) {
+ if (!this.extensionContext) {
+ this.postMessage(buildIndexingSettingsMessage())
+ return undefined
+ }
+ const request = ++this.indexingSettingsRequest
+ const store = indexingConsentStore(this.extensionContext)
+ const projects = await store.list(this.getRootDirectory(), registeredProjects(this.extensionContext))
+ if (request !== this.indexingSettingsRequest) return undefined
+ const id = projects.some((project) => project.id === projectId)
+ ? projectId
+ : projects.some((project) => project.id === this.indexingProjectId)
+ ? this.indexingProjectId
+ : projects[0]?.id
+ this.indexingProjectId = id
+ this.postMessage(buildIndexingSettingsMessage(id ? store.enabled(id) : false, projects, id))
+ return projects.find((project) => project.id === id)
+ }
+
+ private async setIndexingConsent(projectId: string, enabled: boolean): Promise {
+ if (!this.extensionContext) return
+ const store = indexingConsentStore(this.extensionContext)
+ const projects = await store.list(this.getRootDirectory(), registeredProjects(this.extensionContext))
+ const project = projects.find((item) => item.id === projectId)
+ if (!project) return
+ await store.set(project.id, enabled)
+ this.indexingProjectId = project.id
+ await this.sendIndexingSettings(project.id)
+ const config = this.connectionService.getServerConfig()
+ if (!config) return
+ const request = ++this.indexingStatusRequest
+ const status = await this.syncIndexingConsent(project.root, enabled, config)
+ if (request !== this.indexingStatusRequest || this.indexingProjectId !== project.id) return
+ const message = { type: "indexingStatusLoaded", status, projectId: project.id }
+ this.cachedIndexingStatusMessage = message
+ this.postMessage(message)
+ }
+
+ private async syncIndexingConsent(
+ dir: string,
+ enabled: boolean,
+ config: { baseUrl: string; password: string },
+ ): Promise {
+ const auth = Buffer.from(`kilo:${config.password}`).toString("base64")
+ const res = await fetch(`${config.baseUrl}/indexing/consent`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Basic ${auth}`,
+ "Content-Type": "application/json",
+ "x-kilo-directory": dir,
+ },
+ body: JSON.stringify({ enabled }),
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ return (await res.json()) as IndexingStatus
+ }
+
+ private configSettings() {
+ return {
+ maxCost: this.maxCostSetting(),
+ languageCommitMessage: this.commitMessageLanguageSetting(),
+ multiProject: this.multiProjectSetting(),
+ }
+ }
+
+ private settingsDirectory(): string {
+ return this.projectDirectory ?? this.getRootDirectory()
+ }
+
+ private bindingsFor(
+ directory: string,
+ targets:
+ | {
+ global: ConfigTarget
+ project: ConfigTarget
+ }
+ | undefined,
+ ): { global?: ConfigBinding; project?: ConfigBinding } {
+ if (!targets) return {}
+ const project = this.configProject(directory)
+ return {
+ global: this.configBindings.create({
+ connection: this.connectionGeneration,
+ scope: "global",
+ directory,
+ target: this.configTarget(targets.global),
+ }),
+ project: project
+ ? this.configBindings.create({
+ connection: this.connectionGeneration,
+ scope: "project",
+ directory: project.root,
+ target: this.configTarget(targets.project),
+ project,
+ })
+ : undefined,
+ }
+ }
+
+ private configTarget(target: ConfigTarget): ConfigTarget {
+ return { ...target, raw: { ...target.raw } }
+ }
+
+ private configProject(directory: string): ConfigProject | undefined {
+ const root = canonicalizePath(directory)
+ const pinned = canonicalizePath(this.getRootDirectory())
+ if (samePath(root, pinned)) {
+ return { id: projectIdFor(root), root, generation: 0, pinned: true }
+ }
+ if (!this.extensionContext) return undefined
+ const project = registeredProjects(this.extensionContext).find((item) => samePath(item.root, root))
+ if (!project?.trusted || !existsSync(project.root)) return undefined
+ return { id: project.id, root: project.root, generation: 0, pinned: false }
+ }
+
+ private validConfigProject(project: ConfigProject): boolean {
+ if (project.pinned) {
+ return samePath(project.root, canonicalizePath(this.getRootDirectory())) && existsSync(project.root)
+ }
+ if (!this.extensionContext) return false
+ const current = registeredProjects(this.extensionContext).find((item) => item.id === project.id)
+ return !!current?.trusted && samePath(current.root, project.root) && existsSync(current.root)
+ }
+
private setMaxCost(value: unknown): number {
maxCost = MaxCostNudge.normalizeLimit(typeof value === "number" ? value : Number(value)) ?? 0
this.costs.setLimit(maxCost)
@@ -3794,7 +4055,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Re-send all settings to the webview so the UI reflects the reset
this.postMessage(buildAutocompleteSettingsMessage())
- this.postMessage(buildIndexingSettingsMessage())
+ await this.sendIndexingSettings()
this.sendBrowserSettings()
this.sendNotificationSettings()
this.sendTimelineSetting()
@@ -4425,6 +4686,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
private getWorkspaceDirectory(sessionId?: string): string {
+ const routed = this.routeSessionDirectory(sessionId ?? undefined)
+ if (routed === null) throw new Error(`Session ${sessionId} is ambiguous across projects.`)
+ if (routed) return routed
return resolveWorkspaceDirectory({
sessionID: sessionId,
sessionDirectories: this.sessionDirectories,
@@ -4433,9 +4697,41 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
private getSessionDirectory(sessionId: string, session?: Session): string {
+ const routed = this.routeSessionDirectory(sessionId)
+ if (routed === null) throw new Error(`Session ${sessionId} is ambiguous across projects.`)
+ if (routed) return routed
return this.sessionDirectories.get(sessionId) ?? session?.directory ?? this.getRootDirectory()
}
+ /**
+ * Resolve a session directory through the Agent Manager route service.
+ *
+ * Returns the exact directory when the route service knows the id and it is
+ * unambiguous (or a project qualifier disambiguates it). Returns `undefined`
+ * when no route service is configured or the id is unknown — callers then
+ * fall through to the legacy sessionDirectories/workspace-root path, which
+ * preserves non-Agent-Manager behavior. Returns `null` when the id is
+ * ambiguous and cannot be qualified: callers must NOT fall back to an
+ * arbitrary root in that case, because doing so would silently target the
+ * wrong project.
+ */
+ private routeSessionDirectory(sessionId: string | undefined): string | null | undefined {
+ const routes = this.opts.routeService
+ if (!routes || !sessionId) return undefined
+ const exact = routes.trySessionDirectory(sessionId)
+ if (exact) return exact
+ if (routes.isSessionAmbiguous(sessionId)) {
+ const qualifier = this.opts.projectQualifier?.()
+ if (qualifier) {
+ const ref: SessionRef = { projectId: qualifier.projectId, sessionId }
+ const dir = routes.trySessionDirectoryFor(ref)
+ if (dir) return dir
+ }
+ return null
+ }
+ return undefined
+ }
+
private getContextDirectory(): string {
return resolveContextDirectory({
currentSessionID: this.currentSession?.id,
@@ -4446,6 +4742,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
private getRootDirectory(): string {
+ const override = this.opts.rootDirectory?.()
+ if (override) return override
const workspaceFolders = vscode.workspace.workspaceFolders
if (workspaceFolders && workspaceFolders.length > 0) {
return workspaceFolders[0]!.uri.fsPath
@@ -4454,10 +4752,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
private trackDirectory(sessionId: string, dir: string) {
- if (path.resolve(dir) === path.resolve(this.getRootDirectory())) {
- this.sessionDirectories.delete(sessionId)
- return
- }
+ // Agent Manager Local sessions must retain their explicit project root.
+ // Removing same-root entries would silently retarget them after switching
+ // the panel's active project.
this.sessionDirectories.set(sessionId, dir)
}
diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts
index 6e480cc212..da688d95c9 100644
--- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts
+++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts
@@ -2,16 +2,27 @@ import * as fs from "fs"
import * as path from "path"
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import type { KiloConnectionService } from "../services/cli-backend"
-import { getErrorMessage } from "../kilo-provider-utils"
+import { getErrorMessage, sessionToWebview } from "../kilo-provider-utils"
+import { samePath } from "./project-paths"
import { resolveLocalDiffTarget } from "../diff/shared/target"
import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings"
import { isAbsolutePath } from "../path-utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { remoteRef, WorktreeStateManager, type Worktree } from "./WorktreeStateManager"
import { handleSection } from "./section-handler"
+import {
+ addSessionToLifecycleWorktree,
+ closeLifecycleSession,
+ createLifecycleWorktree,
+ deleteLifecycleWorktree,
+ promoteLifecycleSession,
+ removeStaleLifecycleWorktree,
+ type LifecycleDeps,
+} from "./provider-lifecycle"
import { normalizeBaseBranch } from "./base-branch"
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
import { PRStatusBridge } from "./pr-status-bridge"
+import { createPollers, type ProjectPollers } from "./project-pollers"
import { GitOps } from "./GitOps"
import { versionedName } from "./branch-name"
import { BranchNamingController } from "./branch-naming"
@@ -35,11 +46,9 @@ import {
type CreateWorktreeOnDiskOptions,
type CreateWorktreeOnDiskResult,
} from "./worktree-create"
-import { recordPromotionHandoff } from "./promotion-handoff"
-import { restoreWorktrees } from "./state-recovery"
-import { createLocalDiff, diffSummary as localDiffSummary } from "./local-diff"
+import { initContextState, pushProjectSessions, reactivateProject, registerProjectSessions } from "./project-init"
+import { createLocalDiff } from "./local-diff"
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
-import { stopSessionProcesses } from "../kilo-provider/background-process"
import { sandboxSessionMetadata } from "../shared/sandbox-session"
import { AgentManagerOrchestrationBridge } from "./orchestration-bridge"
import { pruneSubagents } from "./prune-subagents"
@@ -51,33 +60,43 @@ import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from
import { ensureSandbox } from "./sandbox-bootstrap"
import { Semaphore } from "./semaphore"
import { PLATFORM } from "./constants"
+import { ProjectRegistry } from "./project-registry"
+import { ProjectContexts, type ProjectContext } from "./project-context"
+import { handleProjectMessage, type ProjectMessageDeps } from "./project-messages"
+import { createProjectWiring } from "./project-wiring"
+import { ProjectScope } from "./project-scope"
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
import type { Host, PanelContext, OutputHandle, Disposable } from "./host"
export class AgentManagerProvider implements Disposable {
public static readonly viewType = "kilo-code.new.AgentManagerPanel"
private panel: PanelContext | undefined
private outputChannel: OutputHandle
- private worktrees: WorktreeManager | undefined
- private state: WorktreeStateManager | undefined
- private setupScript: SetupScriptService | undefined
+ private readonly registry: ProjectRegistry
+ private readonly contexts: ProjectContexts
+ private readonly projects: ProjectMessageDeps
+ private readonly projectScope = new ProjectScope()
private importer: WorktreeImporter
private terminalManager: SessionTerminalManager
private terminalRouter: TerminalRouter
private run: RunController
private stateReady: Promise | undefined
private statsPoller: GitStatsPoller
+ private readonly projectPollers: ProjectPollers
private prBridge!: PRStatusBridge
private orchestration: AgentManagerOrchestrationBridge
private gitOps: GitOps
private diffs: WorktreeDiffController
private naming: BranchNamingController
- private staleWorktreeIds = new Set()
private toolRequests = new Set()
private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined
private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined
private unsubTool: (() => void) | undefined
private unsubStatus: (() => void) | undefined
+ private unsubSessions: (() => void) | undefined
private unsubFont: (() => void) | undefined
+ private unsubProjects: (() => void) | undefined
+ /** Scratch set returned when no active context exists; mutations are discarded. */
+ private readonly staleScratch = new Set()
private closing: Promise | undefined
private onVisibilityChange: ((visible: boolean) => void) | undefined
// Tracks sessions owned by this panel until they are explicitly closed.
@@ -134,6 +153,23 @@ export class AgentManagerProvider implements Disposable {
})
const semaphore = new Semaphore(3)
this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore })
+ const wiring = createProjectWiring({
+ host: this.host,
+ git: this.gitOps,
+ log: (...args) => this.log(...args),
+ output: (msg) => this.outputChannel.appendLine(msg),
+ activate: (ctx) => this.activateProject(ctx),
+ expand: (ctx) => this.initExpanded(ctx),
+ ready: (ctx) => initContextState(ctx, (...args) => this.log(...args)),
+ push: () => this.pushProjects(),
+ changed: () => this.onWorkspaceChanged(),
+ refresh: () => this.pushState(),
+ selected: (target) => this.postToWebview({ type: "agentManager.selectionActivated", target }),
+ })
+ this.registry = wiring.registry
+ this.contexts = wiring.contexts
+ this.projects = wiring.messages
+ this.unsubProjects = () => wiring.dispose()
this.naming = new BranchNamingController({
state: () => this.getStateManager(),
manager: () => this.getWorktreeManager(),
@@ -153,37 +189,25 @@ export class AgentManagerProvider implements Disposable {
post: (msg) => this.postToWebview(msg),
log: (...args) => this.log(...args),
})
- this.statsPoller = new GitStatsPoller({
- getWorktrees: () => this.state?.getWorktrees() ?? [],
- getWorkspaceRoot: () => this.getRoot(),
- localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)),
- semaphore,
- onStats: (stats) => {
- const msg = { type: "agentManager.worktreeStats" as const, stats }
- this.cachedWorktreeStats = msg
- this.postToWebview(msg)
- },
- onLocalStats: (stats) => {
- const msg = { type: "agentManager.localStats" as const, stats }
- this.cachedLocalStats = msg
- this.postToWebview(msg)
- },
- onWorktreePresence: (presence) => {
- this.onWorktreePresence(presence)
- },
- log: (...args) => this.log(...args),
+ const pollers = createPollers({
git: this.gitOps,
- })
- this.prBridge = PRStatusBridge.create({
- getWorktrees: () => this.state?.getWorktrees() ?? [],
- getWorkspaceRoot: () => this.getRoot(),
- postToWebview: (m) => this.postToWebview(m),
- updateWorktreePR: (id, n, u, s) => this.state?.updateWorktreePR(id, n, u, s),
- hasPersistedPR: (id: string) => !!this.state?.getWorktree(id)?.prNumber,
- openExternal: (u) => this.host.openExternal(u),
- log: (...a) => this.log(...a),
semaphore,
+ state: () => this.state,
+ root: () => this.getRoot(),
+ activeId: () => this.contexts.active()?.id,
+ visible: () => this.panel?.visible ?? false,
+ post: (msg) => this.postToWebview(msg),
+ cache: (msg) => {
+ if (msg.type === "agentManager.worktreeStats") this.cachedWorktreeStats = msg
+ if (msg.type === "agentManager.localStats") this.cachedLocalStats = msg
+ },
+ presence: (presence) => this.onWorktreePresence(presence),
+ openExternal: (u) => this.host.openExternal(u),
+ log: (...args) => this.log(...args),
})
+ this.statsPoller = pollers.stats
+ this.prBridge = pollers.pr
+ this.projectPollers = pollers.projects
this.orchestration = new AgentManagerOrchestrationBridge(this.connectionService, {
root: () => this.getRoot(),
state: () => this.state,
@@ -209,6 +233,48 @@ export class AgentManagerProvider implements Disposable {
(event) => (event as { type?: string }).type === "session.status",
(event) => this.onSessionStatus(event),
)
+ this.unsubSessions = this.connectionService.onEventFiltered(
+ (event) => {
+ const type = (event as { type?: string }).type
+ return type === "session.created" || type === "session.updated" || type === "session.deleted"
+ },
+ (event) => this.onSessionLifecycle(event),
+ )
+ }
+
+ /**
+ * Keep each project's cached sidebar session list in sync with backend
+ * session lifecycle events, so sessions created outside this panel (another
+ * window, the CLI, the API) appear without waiting for a full re-list.
+ */
+ private onSessionLifecycle(event: unknown): void {
+ const ev = event as { type?: string; properties?: { info?: Session; sessionID?: string } }
+ if (ev.type === "session.deleted") {
+ const id = ev.properties?.sessionID
+ if (!id) return
+ const ctx = this.contexts.byLiveSession(id)
+ if (!ctx) return
+ ctx.removeLiveSession(id)
+ this.postToWebview({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] })
+ return
+ }
+ const info = ev.properties?.info
+ const dir = info?.directory
+ // Session events from sync or older backends can lack time/directory; a
+ // throw here would escape into the SSE dispatch loop and starve the other
+ // listeners (there is no per-listener error isolation).
+ if (!info?.time || !dir) return
+ const ctx = this.contexts.byDirectory(dir)
+ if (!ctx || ctx.lifecycle !== "ready") return
+ const state = ctx.peekState()
+ const managed = state?.getSession(info.id)
+ const worktreeId =
+ managed?.worktreeId ?? state?.getWorktrees().find((wt) => wt.path && samePath(wt.path, dir))?.id ?? null
+ ctx.upsertSession({ ...sessionToWebview(info), worktreeId })
+ // The next regular push re-lists from the backend to reconcile the
+ // optimistic entry (position, subtrees, deletions elsewhere).
+ ctx.invalidateSessions()
+ this.postToWebview({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] })
}
private onSessionStatus(event: unknown): void {
@@ -239,6 +305,8 @@ export class AgentManagerProvider implements Disposable {
this.host.openPanel({
onBeforeMessage: (msg) => this.onMessage(msg),
worktreeDirectories: () => this.getWorktreeDirectories(),
+ workspaceRoot: () => this.getRoot(),
+ projectId: () => this.contexts.active()?.id,
}),
)
}
@@ -275,9 +343,11 @@ export class AgentManagerProvider implements Disposable {
this.panel = ctx
this.statsPoller.setVisible(ctx.visible)
+ this.projectPollers.setVisible(ctx.visible)
this.onVisibilityChange?.(ctx.visible)
ctx.onDidChangeVisibility((visible) => {
this.statsPoller.setVisible(visible)
+ this.projectPollers.setVisible(visible)
this.visiblePresence.flush()
})
@@ -286,6 +356,7 @@ export class AgentManagerProvider implements Disposable {
})
this.stateReady = this.initializeState()
+ this.pushProjects()
void this.sendRepoInfo()
this.sendKeybindings()
this.prBridge.attachPanel(ctx)
@@ -310,47 +381,39 @@ export class AgentManagerProvider implements Disposable {
})
}
- // State initialization
-
private async initializeState(): Promise {
- const manager = this.getWorktreeManager()
- const state = this.getStateManager()
- if (!manager || !state) {
+ const ctx = this.contexts.active()
+ if (!ctx) {
this.pushEmptyState()
return
}
+ const state = ctx.stateManager()
+ const init = await initContextState(ctx, (...args) => this.log(...args))
- await this.ensureGitExclude(manager)
- const loaded = await state.load()
- manager.cleanupOrphanedTempDirs()
-
- if (loaded.status === "failed" && !(await state.prepareRecovery())) {
+ if (!init.ok) {
this.postToWebview({ type: "error", message: "Agent Manager state could not be recovered." })
this.pushState()
return
}
- await this.recoverWorktrees(manager, state)
-
// When the .kilocode → .kilo migration rewrote git worktree refs, nudge
// VS Code's git extension to re-discover them. Without this, worktrees
// won't appear in Source Control until the next VS Code restart.
- if (loaded.refsFixed > 0) {
- this.log(`Migration fixed ${loaded.refsFixed} git worktree ref(s), refreshing git`)
+ if (init.refsFixed > 0) {
+ this.log(`Migration fixed ${init.refsFixed} git worktree ref(s), refreshing git`)
this.host.refreshGit()
}
- for (const wt of state.getWorktrees()) {
- for (const s of state.getSessions(wt.id)) this.panel?.sessions.setSessionDirectory(s.id, wt.path)
- }
+ registerProjectSessions(ctx, this.panel?.sessions)
await pruneSubagents(state, this.panel?.sessions, (message) => this.log(message))
for (const s of state.getSessions()) this.panel?.sessions.trackSession(s.id)
this.pushState()
- // Refresh sessions so worktree sessions appear in the list
- if (state.getSessions().length > 0) {
- this.panel?.sessions.refreshSessions()
- }
+ // Always list sessions, even when the state tracks none: the backend may
+ // still hold sessions for this project, and without the listing the
+ // sessionsLoaded message never reaches the webview, leaving the sidebar
+ // on skeletons forever.
+ this.panel?.sessions.refreshSessions()
// Recover any pending permission/question prompts that were missed during
// panel recreation or SSE reconnection. Must run after all worktree sessions
@@ -359,26 +422,16 @@ export class AgentManagerProvider implements Disposable {
this.panel?.sessions.recoverPendingPrompts()
}
- private async ensureGitExclude(manager: WorktreeManager): Promise {
- await manager.ensureGitExclude().catch((err) => {
- this.log("Failed to update git exclude:", err)
+ /** Initialize an expanded background project and push its state (no panel wiring). */
+ private initExpanded(ctx: ProjectContext): void {
+ void initContextState(ctx, (...args) => this.log(...args)).then((result) => {
+ if (!result.current) return
+ registerProjectSessions(ctx, this.panel?.sessions)
+ this.pushState(ctx)
+ this.projectPollers.sync(this.contexts)
})
}
- private async recoverWorktrees(manager: WorktreeManager, state: WorktreeStateManager): Promise {
- const infos = await manager.discoverWorktrees().catch((err) => {
- this.log("Failed to discover worktrees during state recovery:", err)
- return []
- })
- if (infos.length === 0) return
-
- const result = restoreWorktrees(state, infos)
- if (result.worktrees === 0 && result.sessions === 0) return
-
- this.log(`Recovered ${result.worktrees} worktree(s) and ${result.sessions} session(s) from disk`)
- await state.flush()
- }
-
// Message interceptor
private async onMessage(msg: Record): Promise | null> {
@@ -388,7 +441,27 @@ export class AgentManagerProvider implements Disposable {
}
msg = await this.contextMessage(msg)
const m = msg as unknown as AgentManagerInMessage
- if (this.shouldWaitForState(m)) await this.waitForStateReady(m.type)
+ if (await handleProjectMessage(m, this.projects)) return null
+ const ctx = this.messageProject(m)
+ if (!ctx) {
+ this.log(`dropping ${m.type}: message targets an unknown project`)
+ return null
+ }
+ return this.projectScope.run(ctx, () => this.dispatchMessage(m, msg, ctx))
+ }
+
+ private async dispatchMessage(
+ m: AgentManagerInMessage,
+ msg: Record,
+ ctx: ProjectContext,
+ ): Promise | null> {
+ if (this.shouldWaitForState(m)) {
+ const result = await initContextState(ctx, (...args) => this.log(...args))
+ if (!result.current || !result.ok) {
+ this.log(`dropping ${m.type}: project ${ctx.id} not ready (current=${result.current}, ok=${result.ok})`)
+ return null
+ }
+ }
this.onBranchPrompt(m)
const worktree = await this.onWorktreeMessage(m)
@@ -726,6 +799,9 @@ export class AgentManagerProvider implements Disposable {
}
private onRequestState(): void {
+ this.log(
+ `onRequestState: stateReady=${this.stateReady ? "pending" : "missing"}, state=${this.state ? "ok" : "missing"}`,
+ )
void this.stateReady
?.then(() => {
// When the folder is not a git repo (or has no folder open),
@@ -746,9 +822,9 @@ export class AgentManagerProvider implements Disposable {
// onMount). Without this, the initial refreshSessions() in
// initializeState() can race ahead of webview mount, causing
// sessionsLoaded to never flip to true.
- if (this.state.getSessions().length > 0) {
- this.panel?.sessions.refreshSessions()
- }
+ // Unconditional: an empty managed list still needs the backend listing
+ // so the webview's sessionsLoaded gate can flip.
+ this.panel?.sessions.refreshSessions()
})
.catch((err) => {
this.log("initializeState failed, pushing partial state:", err)
@@ -966,7 +1042,7 @@ export class AgentManagerProvider implements Disposable {
registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir),
notifyReady: (sid, result, wid) => this.notifyWorktreeReady(sid, result, wid),
push: () => this.pushState(),
- post: (msg) => this.postToWebview(msg as AgentManagerOutMessage),
+ post: (msg) => this.postToWebview(msg as unknown as AgentManagerOutMessage),
capture: (event, props) => this.host.capture(event, props),
log: (...args) => this.log(...args),
error: (msg) => this.host.showError(msg),
@@ -979,221 +1055,27 @@ export class AgentManagerProvider implements Disposable {
/** Create a new worktree with an auto-created first session. */
private async onCreateWorktree(baseBranch?: string, branchName?: string): Promise {
- await this.waitForStateReady("onCreateWorktree")
-
- const created = await this.createWorktreeOnDisk({ baseBranch, branchName })
- if (!created) return null
-
- // Run setup script for new worktree (blocks until complete, shows in overlay)
- await this.runSetupScriptForWorktree(created.result.path, created.result.branch, created.worktree.id)
-
- const session = await this.createSessionInWorktree(created.result.path, created.result.branch, created.worktree.id)
- if (!session) {
- const state = this.getStateManager()
- const manager = this.getWorktreeManager()
- state?.removeWorktree(created.worktree.id)
- await manager?.removeWorktree(created.result.path)
- this.pushState()
- return null
- }
-
- const state = this.getStateManager()!
- state.addSession(session.id, created.worktree.id)
- if (!branchName && this.host.autoBranchNaming().enabled) state.armAutoName(created.worktree.id, session.id)
- this.registerWorktreeSession(session.id, created.result.path)
- // Push state before registerSession so the webview's sessionCreated handler
- // sees the worktree mapping and routes the session to the worktree tab.
- this.notifyWorktreeReady(session.id, created.result, created.worktree.id)
- this.panel?.sessions.registerSession(session)
- this.host.capture("Agent Manager Session Started", {
- source: PLATFORM,
- sessionId: session.id,
- worktreeId: created.worktree.id,
- branch: created.result.branch,
- })
- this.log(`Created worktree ${created.worktree.id} with session ${session.id}`)
- return null
+ return createLifecycleWorktree(this.lifecycle, { baseBranch, branchName })
}
/** Delete a worktree and dissociate its sessions. */
private async onDeleteWorktree(worktreeId: string): Promise {
- const manager = this.getWorktreeManager()
- const state = this.getStateManager()
- if (!manager || !state) return null
- const worktree = state.getWorktree(worktreeId)
- if (!worktree) {
- this.log(`Worktree ${worktreeId} not found in state`)
- return null
- }
- // Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree.
- // Pre-emptive skip covers any in-flight poll that already captured getWorktrees().
- this.statsPoller.skipWorktree(worktreeId)
- this.prBridge.remove(worktreeId)
- this.run.remove(worktreeId)
- this.naming.forget(worktreeId)
- const orphaned = state.removeWorktree(worktreeId)
- if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) {
- this.diffs.stop()
- }
- for (const s of orphaned) this.panel?.sessions.clearSessionDirectory(s.id)
- this.pushState()
- // Disk removal after state is clean — pollers no longer reference this worktree.
- const branch = worktree.branchOwned === false ? undefined : (worktree.originalBranch ?? worktree.branch)
- try {
- await manager.removeWorktree(worktree.path, branch)
- } catch (error) {
- this.log(`Failed to remove worktree from disk: ${error}`)
- }
- this.log(`Deleted worktree ${worktreeId}${branch ? ` (${branch})` : ""}`)
- return null
+ return deleteLifecycleWorktree(this.lifecycle, worktreeId)
}
/** Remove a stale worktree entry from state without touching the filesystem. */
private async onRemoveStaleWorktree(worktreeId: string): Promise {
- const state = this.getStateManager()
- if (!state) return null
- if (!this.staleWorktreeIds.has(worktreeId)) {
- this.log(`Ignored stale removal for non-stale worktree ${worktreeId}`)
- return null
- }
-
- const worktree = state.getWorktree(worktreeId)
- if (!worktree) {
- this.clearStaleTracking(worktreeId)
- this.pushState()
- return null
- }
-
- this.naming.forget(worktreeId)
- const orphaned = state.removeWorktree(worktreeId)
- if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) {
- this.diffs.stop()
- }
- for (const session of orphaned) {
- this.panel?.sessions.clearSessionDirectory(session.id)
- }
- this.clearStaleTracking(worktreeId)
- this.pushState()
- this.log(`Removed stale worktree entry ${worktreeId} (${worktree.branch})`)
- return null
+ return removeStaleLifecycleWorktree(this.lifecycle, worktreeId)
}
/** Promote a session: create a worktree and move the session into it. */
private async onPromoteSession(sessionId: string): Promise {
- await this.waitForStateReady("onPromoteSession")
- const created = await this.createWorktreeOnDisk({})
- if (!created) return null
-
- // Run setup script for new worktree (blocks until complete, shows in overlay)
- await this.runSetupScriptForWorktree(created.result.path, created.result.branch, created.worktree.id)
-
- const state = this.getStateManager()!
- if (!state.getSession(sessionId)) {
- state.addSession(sessionId, created.worktree.id)
- } else {
- state.moveSession(sessionId, created.worktree.id)
- }
-
- this.registerWorktreeSession(sessionId, created.result.path)
- await this.recordPromotionHandoff(sessionId, created.result.path, created.result.branch)
- this.notifyWorktreeReady(sessionId, created.result, created.worktree.id)
- this.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`)
- return null
- }
-
- private async recordPromotionHandoff(sessionId: string, dir: string, branch: string): Promise {
- try {
- await recordPromotionHandoff({
- client: this.connectionService.getClient(),
- sessionId,
- directory: dir,
- branch,
- })
- } catch (err) {
- this.log("Failed to record worktree promotion handoff:", getErrorMessage(err))
- }
+ return promoteLifecycleSession(this.lifecycle, sessionId)
}
/** Add a new session to an existing worktree. */
private async onAddSessionToWorktree(worktreeId: string, sessionId?: string): Promise {
- let client: KiloClient
- try {
- client = this.connectionService.getClient()
- } catch (err) {
- this.log("onAddSessionToWorktree: client not available:", err)
- this.postToWebview({ type: "error", message: "Not connected to CLI backend" })
- return null
- }
-
- const state = this.getStateManager()
- if (!state) return null
-
- const worktree = state.getWorktree(worktreeId)
- if (!worktree) {
- this.log(`Worktree ${worktreeId} not found`)
- return null
- }
-
- if (sessionId) {
- if (state.getSession(sessionId)) state.moveSession(sessionId, worktreeId)
- else state.addSession(sessionId, worktreeId)
- this.registerWorktreeSession(sessionId, worktree.path)
- this.pushState()
- this.postToWebview({
- type: "agentManager.sessionAdded",
- sessionId,
- worktreeId,
- })
- this.host.capture("Agent Manager Session Started", {
- source: PLATFORM,
- sessionId,
- worktreeId,
- existing: true,
- })
- this.log(`Added existing session ${sessionId} to worktree ${worktreeId}`)
- return null
- }
-
- let session: Session
- try {
- const metadata = await sandboxSessionMetadata(this.connectionService.sandboxPreference, client, worktree.path)
- const { data } = await client.session.create(
- { directory: worktree.path, platform: PLATFORM, metadata },
- { throwOnError: true },
- )
- session = data
- } catch (error) {
- const err = getErrorMessage(error)
- this.postToWebview({ type: "error", message: `Failed to create session: ${err}` })
- this.host.capture("Agent Manager Session Error", {
- source: PLATFORM,
- error: err,
- context: "addSessionToWorktree",
- worktreeId,
- })
- return null
- }
-
- state.addSession(session.id, worktreeId)
- this.registerWorktreeSession(session.id, worktree.path)
- this.pushState()
- this.postToWebview({
- type: "agentManager.sessionAdded",
- sessionId: session.id,
- worktreeId,
- })
-
- if (this.panel) {
- this.panel.sessions.registerSession(session)
- }
-
- this.host.capture("Agent Manager Session Started", {
- source: PLATFORM,
- sessionId: session.id,
- worktreeId,
- })
- this.log(`Added session ${session.id} to worktree ${worktreeId}`)
- return null
+ return addSessionToLifecycleWorktree(this.lifecycle, worktreeId, sessionId)
}
private onForkSession(sessionId: string, worktreeId?: string, messageId?: string) {
@@ -1223,22 +1105,7 @@ export class AgentManagerProvider implements Disposable {
/** Stop a session and remove it from Agent Manager. */
private async onCloseSession(sessionId: string): Promise {
- const state = this.getStateManager()
- const dirs = this.panel?.sessions.getSessionDirectories()
- const dir = state?.directoryFor(sessionId) ?? dirs?.get(sessionId) ?? this.getRoot() ?? process.cwd()
- await this.panel?.sessions.abortSessions([sessionId])
- this.panelSessions.delete(sessionId)
- try {
- await stopSessionProcesses(this.connectionService.getClient(), sessionId, dir)
- } catch (err) {
- this.log("onCloseSession: client not available:", err)
- }
-
- state?.removeSession(sessionId)
- this.panel?.sessions.clearSessionDirectory(sessionId)
- if (state) this.pushState()
- this.log(`Closed session ${sessionId}`)
- return null
+ return closeLifecycleSession(this.lifecycle, sessionId)
}
// Multi-version worktree creation
@@ -1565,7 +1432,9 @@ export class AgentManagerProvider implements Disposable {
const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId))
const staleChanged =
next.size !== this.staleWorktreeIds.size || [...next].some((worktreeId) => !this.staleWorktreeIds.has(worktreeId))
- this.staleWorktreeIds = next
+ const stale = this.staleWorktreeIds
+ stale.clear()
+ for (const id of next) stale.add(id)
if (staleChanged || branchChanged) {
this.pushState()
@@ -1600,23 +1469,27 @@ export class AgentManagerProvider implements Disposable {
}
const stats = this.statsPoller.syncSkips(skipped)
if (!stats) return
- const msg = { type: "agentManager.worktreeStats" as const, stats }
+ const msg = { type: "agentManager.worktreeStats" as const, projectId: this.contexts.active()?.id, stats }
this.cachedWorktreeStats = msg
this.postToWebview(msg)
}
- private pushState(): void {
- const state = this.state
- if (!state) return
+ private pushState(ctx?: ProjectContext): void {
+ const target = ctx ?? this.context
+ const state = target?.peekState()
+ if (!target || !state) {
+ this.log(`pushState skipped: target=${target?.id ?? "none"}, state=${state ? "ok" : "missing"}`)
+ return
+ }
+ const active = this.contexts.active()?.id === target.id
const worktrees = state.getWorktrees()
- const staleWorktreeIds = this.staleWorktreesForState(worktrees)
- const run = this.run.state()
this.postToWebview({
type: "agentManager.state",
+ projectId: target.id,
worktrees,
sessions: state.getSessions(),
sections: state.getSections(),
- staleWorktreeIds,
+ staleWorktreeIds: active ? this.staleWorktreesForState(worktrees) : [],
tabOrder: state.getTabOrder(),
worktreeOrder: state.getWorktreeOrder(),
sessionsCollapsed: state.getSessionsCollapsed(),
@@ -1625,8 +1498,11 @@ export class AgentManagerProvider implements Disposable {
reviewMarkdownRender: getDiffMarkdownRender(),
isGitRepo: true,
defaultBaseBranch: state.getDefaultBaseBranch(),
- ...run,
+ activeTarget: state.getActiveTarget(),
+ ...(active ? this.run.state() : {}),
})
+ void pushProjectSessions(target, this.panel?.sessions, (message) => this.postToWebview(message))
+ if (!active) return
// Sync skip set before enabling the poller so the first poll cycle
// already excludes worktrees in collapsed sections.
@@ -1651,47 +1527,110 @@ export class AgentManagerProvider implements Disposable {
})
}
- // Manager accessors
+ // Manager accessors — repository-bound services are owned by the active ProjectContext (immutable per root).
+ private get lifecycle(): LifecycleDeps {
+ return {
+ waitReady: (context) => this.waitForStateReady(context),
+ createOnDisk: (opts) => this.createWorktreeOnDisk(opts),
+ setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id),
+ createSession: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id),
+ state: () => this.getStateManager(),
+ manager: () => this.getWorktreeManager(),
+ push: () => this.pushState(),
+ register: (sid, dir) => this.registerWorktreeSession(sid, dir),
+ ready: (sid, result, id) => this.notifyWorktreeReady(sid, result, id),
+ registerSession: (session) => this.panel?.sessions.registerSession(session),
+ clearDirectory: (sid) => this.panel?.sessions.clearSessionDirectory(sid),
+ directories: () => this.panel?.sessions.getSessionDirectories(),
+ abort: (ids) => this.panel?.sessions.abortSessions(ids) ?? Promise.resolve(),
+ forgetPanel: (sid) => void this.panelSessions.delete(sid),
+ skipStats: (id) => this.statsPoller.skipWorktree(id),
+ removePR: (id) => this.prBridge.remove(id),
+ removeRun: (id) => this.run.remove(id),
+ forgetName: (id) => this.naming.forget(id),
+ stale: () => this.staleWorktreeIds,
+ clearStale: (id) => this.clearStaleTracking(id),
+ stopDiffsFor: (path, orphaned) => {
+ if (this.diffs.shouldStopForWorktree(path, orphaned)) this.diffs.stop()
+ },
+ capture: (event, props) => this.host.capture(event, props),
+ autoName: () => this.host.autoBranchNaming(),
+ client: () => this.connectionService.getClient(),
+ metadata: (client, dir) => sandboxSessionMetadata(this.connectionService.sandboxPreference, client, dir),
+ root: () => this.getRoot(),
+ post: (msg) => this.postToWebview(msg as unknown as AgentManagerOutMessage),
+ log: (...args) => this.log(...args),
+ }
+ }
+
+ private get context(): ProjectContext | undefined {
+ return this.projectScope.current() ?? this.contexts.active()
+ }
private getRoot(): string | undefined {
- return this.host.workspacePath()
+ return this.context?.root
}
private getWorktreeManager(): WorktreeManager | undefined {
- if (this.worktrees) return this.worktrees
- const root = this.getRoot()
- if (!root) {
- this.log("getWorktreeManager: no folder available")
- return undefined
- }
- this.worktrees = new WorktreeManager(
- root,
- (msg) => this.outputChannel.appendLine(`[WorktreeManager] ${msg}`),
- this.gitOps,
- )
- return this.worktrees
+ return this.context?.worktreeManager()
}
private getStateManager(): WorktreeStateManager | undefined {
- if (this.state) return this.state
- const root = this.getRoot()
- if (!root) {
- this.log("getStateManager: no folder available")
- return undefined
- }
- this.state = new WorktreeStateManager(root, (msg) => this.outputChannel.appendLine(`[StateManager] ${msg}`))
- return this.state
+ return this.context?.stateManager()
}
private getSetupScriptService(): SetupScriptService | undefined {
- if (this.setupScript) return this.setupScript
- const root = this.getRoot()
- if (!root) {
- this.log("getSetupScriptService: no folder available")
- return undefined
+ return this.context?.setupService()
+ }
+
+ private get state(): WorktreeStateManager | undefined {
+ return this.context?.peekState()
+ }
+ private get worktrees(): WorktreeManager | undefined {
+ return this.context?.peekWorktrees()
+ }
+
+ private get setupScript(): SetupScriptService | undefined {
+ return this.context?.peekSetup()
+ }
+
+ private get staleWorktreeIds(): Set {
+ return this.context?.stale ?? this.staleScratch
+ }
+
+ private messageProject(m: AgentManagerInMessage): ProjectContext | undefined {
+ const pid = (m as { projectId?: unknown }).projectId
+ if (typeof pid !== "string") return this.contexts.active()
+ return this.contexts.get(pid)
+ }
+
+ private activateProject(ctx: ProjectContext): void {
+ if (this.contexts.active()?.id !== ctx.id) return
+ this.statsPoller.stop()
+ this.prBridge.reset()
+ this.activeSessionId = undefined
+ this.cachedWorktreeStats = this.cachedLocalStats = undefined
+ void this.sendRepoInfo()
+ if (!reactivateProject(ctx, this.panel?.sessions, (c) => this.pushState(c)))
+ this.stateReady = this.initializeState()
+ else this.projectPollers.sync(this.contexts)
+ }
+ private onWorkspaceChanged(): void {
+ if (this.contexts.syncPinned()) {
+ this.activeSessionId = undefined
+ this.stateReady = this.initializeState()
+ void this.sendRepoInfo()
}
- this.setupScript = new SetupScriptService(root)
- return this.setupScript
+ this.pushProjects()
+ }
+
+ private pushProjects(): void {
+ this.postToWebview({
+ type: "agentManager.projects",
+ multiProject: this.host.multiProject(),
+ projects: this.contexts.snapshots(),
+ })
+ this.projectPollers.sync(this.contexts)
}
// Worktree file helpers
@@ -1832,6 +1771,9 @@ export class AgentManagerProvider implements Disposable {
)
}
+ public workspaceRoot = () => this.getRoot()
+
+ public projectId = () => this.contexts.active()?.id
/**
* Continue a sidebar session in a new worktree.
* Captures git state, creates worktree, applies state, forks session.
@@ -1900,7 +1842,12 @@ export class AgentManagerProvider implements Disposable {
}
private handleSection(m: AgentManagerInMessage): boolean {
- return handleSection(this.state, m, () => this.pushState())
+ return handleSection(
+ this.state,
+ m,
+ () => this.pushState(),
+ (...args) => this.log(...args),
+ )
}
public postMessage(message: unknown): void {
@@ -1918,15 +1865,18 @@ export class AgentManagerProvider implements Disposable {
private async disposeAsync(): Promise {
await this.stateReady?.catch((err) => this.log("dispose: stateReady rejected:", err))
- await this.state?.flush().catch((err) => this.log("dispose: state flush failed:", err))
+ await this.contexts.dispose()
this.unsubTool?.()
this.unsubStatus?.()
+ this.unsubSessions?.()
this.unsubFont?.()
+ this.unsubProjects?.()
this.orchestration.dispose()
this.visiblePresence.clear()
this.diffs.stop()
this.naming.dispose()
this.statsPoller.stop()
+ this.projectPollers.dispose()
this.gitOps.dispose()
this.prBridge.poller.stop()
this.run.dispose()
diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts
index ce386a858a..5fa2fb1191 100644
--- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts
+++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts
@@ -3,7 +3,7 @@ import * as path from "path"
import { remoteRef, type Worktree } from "./WorktreeStateManager"
import type { GitOps } from "./GitOps"
import type { Semaphore } from "./semaphore"
-import { normalizePath } from "./git-import"
+import { findTrackedBranch } from "./project-paths"
import type { WorktreeDiffEntry } from "./types"
export interface WorktreeStats {
@@ -69,6 +69,7 @@ export class GitStatsPoller {
private readonly git: GitOps
private skipWorktreeIds = new Set()
private visible = true
+ private generation = 0
constructor(private readonly options: GitStatsPollerOptions) {
this.intervalMs = options.intervalMs ?? 5000
@@ -113,6 +114,7 @@ export class GitStatsPoller {
}
stop(): void {
+ this.generation++
this.active = false
if (this.timer) {
clearTimeout(this.timer)
@@ -153,21 +155,26 @@ export class GitStatsPoller {
if (!this.active) return Promise.resolve()
if (this.busy) return Promise.resolve()
this.busy = true
- return this.fetch().finally(() => {
+ const generation = this.generation
+ return this.fetch(generation).finally(() => {
+ // stop() already reset busy and bumped the generation, so a stale
+ // fetch must not touch busy: a restarted poll may own it right now.
+ if (generation !== this.generation) return
this.busy = false
this.schedule(this.currentInterval())
})
}
- private async fetch(): Promise {
- await Promise.all([this.fetchWorktreeStats(), this.fetchLocalStats()])
+ private async fetch(generation = this.generation): Promise {
+ await Promise.all([this.fetchWorktreeStats(false, generation), this.fetchLocalStats(generation)])
}
- private async fetchWorktreeStats(includeSkipped = false): Promise {
+ private async fetchWorktreeStats(includeSkipped = false, generation = this.generation): Promise {
const worktrees = this.options.getWorktrees()
if (worktrees.length === 0) return
const presence = await this.probeWorktreePresence(worktrees)
+ if (generation !== this.generation) return
this.options.onWorktreePresence?.(presence)
const missing = new Set(
@@ -211,6 +218,7 @@ export class GitStatsPoller {
}),
)
).filter((item): item is WorktreeStats => !!item)
+ if (generation !== this.generation) return
for (const item of stats) this.lastStats[item.worktreeId] = item
@@ -248,13 +256,12 @@ export class GitStatsPoller {
const worktreeStatuses = await Promise.all(
worktrees.map(async (wt) => {
const abs = path.isAbsolute(wt.path) ? wt.path : path.join(root, wt.path)
- const normalized = normalizePath(abs)
const exists = await fs.promises.access(abs).then(
() => true,
() => false,
)
- const missing = !exists || !tracked.has(normalized)
- const branch = tracked.get(normalized)
+ const branch = exists ? findTrackedBranch(tracked, abs) : undefined
+ const missing = !exists || branch === undefined
return { worktreeId: wt.id, missing, branch }
}),
)
@@ -262,7 +269,7 @@ export class GitStatsPoller {
return { worktrees: worktreeStatuses, degraded: false }
}
- private async fetchLocalStats(): Promise {
+ private async fetchLocalStats(generation = this.generation): Promise {
const root = this.options.getWorkspaceRoot()
if (!root) return
@@ -301,6 +308,7 @@ export class GitStatsPoller {
if (this.lastLocalStats && this.lastLocalStats.branch === branch) return
return
}
+ if (generation !== this.generation) return
const hash = `local:${branch}:${files}:${additions}:${deletions}:${ahead}:${behind}`
if (hash === this.lastLocalHash) {
diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts
index f655616b96..155bd410bb 100644
--- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts
+++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts
@@ -38,6 +38,11 @@ export class PRStatusPoller {
private lastFullSync = 0 // timestamp of last full (all-worktree) sync
private readonly intervalMs: number
private readonly semaphore: Semaphore | undefined
+ private generation = 0
+
+ private stale(generation: number): boolean {
+ return generation !== this.generation
+ }
constructor(private readonly options: PRStatusPollerOptions) {
this.intervalMs = options.intervalMs ?? 15_000
@@ -87,6 +92,7 @@ export class PRStatusPoller {
}
stop(): void {
+ this.generation++
this.active = false
if (this.timer) {
clearTimeout(this.timer)
@@ -144,7 +150,11 @@ export class PRStatusPoller {
if (!this.active || !this.visible) return Promise.resolve()
if (this.busy) return Promise.resolve()
this.busy = true
- return this.fetchAll().finally(() => {
+ const generation = this.generation
+ return this.fetchAll(generation).finally(() => {
+ // stop() already reset busy and bumped the generation, so a stale
+ // fetch must not touch busy: a restarted poll may own it right now.
+ if (this.stale(generation)) return
this.busy = false
this.schedule()
})
@@ -165,8 +175,9 @@ export class PRStatusPoller {
return this.ghAvailable
}
- private async fetchAll(): Promise {
+ private async fetchAll(generation = this.generation): Promise {
if (!(await this.probeGh())) {
+ if (generation !== this.generation) return
// De-duplicate: only emit gh_missing once, not every poll cycle
if (this.lastError !== "gh_missing") {
this.lastError = "gh_missing"
@@ -196,10 +207,11 @@ export class PRStatusPoller {
return
}
- const thunks = targets.map((wt) => () => this.fetchOne(wt.id))
+ const thunks = targets.map((wt) => () => this.fetchOne(wt.id, generation))
const results = full
? await settled(thunks, FULL_SYNC_CONCURRENCY)
: await Promise.allSettled(thunks.map((fn) => fn()))
+ if (this.stale(generation)) return
const ok = results.every((r) => r.status === "fulfilled")
if (ok) {
this.failures = 0
@@ -208,16 +220,14 @@ export class PRStatusPoller {
this.failures++
}
- private async fetchOne(worktreeId: string): Promise {
- const worktrees = this.options.getWorktrees()
- const wt = worktrees.find((w) => w.id === worktreeId)
+ private async fetchOne(worktreeId: string, generation = this.generation): Promise {
+ const wt = this.target(worktreeId)
if (!wt) return
- if (!this.options.getWorkspaceRoot()) return
-
try {
const pr = await this.cachedFetchPR(wt.branch, wt.path)
- if (!pr) {
+ if (!pr || this.stale(generation)) {
+ if (this.stale(generation)) return
const hash = `${worktreeId}:none`
if (this.lastHash.get(worktreeId) === hash) return
this.lastHash.set(worktreeId, hash)
@@ -229,6 +239,7 @@ export class PRStatusPoller {
this.fetchChecks(pr.number, wt.path),
this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined,
])
+ if (this.stale(generation)) return
const status: PRStatus = {
number: pr.number,
@@ -249,23 +260,28 @@ export class PRStatusPoller {
this.options.onStatus(worktreeId, status)
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err)
- const kind = classifyPRError(msg)
- this.options.log(`PR fetch failed for ${wt.branch}:`, msg)
-
- const errKey = kind === "gh_missing" ? "gh_missing" : kind === "gh_auth" ? "gh_auth" : "fetch_failed"
- if (kind === "gh_missing") this.ghAvailable = false
-
- // De-duplicate: only emit if the error state changed for this worktree
- const hash = `${worktreeId}:error:${errKey}`
- if (this.lastHash.get(worktreeId) !== hash) {
- this.lastHash.set(worktreeId, hash)
- this.options.onStatus(worktreeId, null, errKey)
- }
+ this.handleError(worktreeId, wt.branch, err)
throw err // propagate so fetchAll can track failures for backoff
}
}
+ private handleError(worktreeId: string, branch: string, err: unknown): void {
+ const msg = err instanceof Error ? err.message : String(err)
+ const kind = classifyPRError(msg)
+ this.options.log(`PR fetch failed for ${branch}:`, msg)
+ const key = kind === "gh_missing" ? "gh_missing" : kind === "gh_auth" ? "gh_auth" : "fetch_failed"
+ if (kind === "gh_missing") this.ghAvailable = false
+ const hash = `${worktreeId}:error:${key}`
+ if (this.lastHash.get(worktreeId) === hash) return
+ this.lastHash.set(worktreeId, hash)
+ this.options.onStatus(worktreeId, null, key)
+ }
+
+ private target(worktreeId: string): Worktree | undefined {
+ if (!this.options.getWorkspaceRoot()) return
+ return this.options.getWorktrees().find((worktree) => worktree.id === worktreeId)
+ }
+
private static readonly PR_JSON_FIELDS =
"number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts
index cca28e5c3e..5ce62844b6 100644
--- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts
+++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts
@@ -12,6 +12,7 @@
import * as path from "path"
import * as fs from "fs"
import { normalizePath } from "./git-import"
+import type { SidebarTarget } from "./project-route"
export interface Worktree {
id: string
@@ -79,6 +80,7 @@ interface StateFile {
sidebarCollapsed?: boolean
reviewDiffStyle?: "unified" | "split"
defaultBaseBranch?: string
+ activeTarget?: SidebarTarget
}
export type StateLoadStatus = "loaded" | "missing" | "failed"
@@ -108,6 +110,7 @@ export class WorktreeStateManager {
private sidebar = false
private reviewDiffStyle: "unified" | "split" = "unified"
private defaultBase: string | undefined
+ private activeTarget: SidebarTarget | undefined
private readonly log: (msg: string) => void
private saving: Promise | undefined
private dirty = false
@@ -385,6 +388,23 @@ export class WorktreeStateManager {
void this.save()
}
+ /** Last selected sidebar target (Local/worktree/session) for seamless restore. */
+ getActiveTarget(): SidebarTarget | undefined {
+ return this.activeTarget
+ }
+
+ setActiveTarget(target: SidebarTarget | undefined): void {
+ const cur = this.activeTarget
+ const same =
+ cur?.kind === target?.kind &&
+ cur?.projectId === target?.projectId &&
+ (cur?.kind !== "worktree" || target?.kind !== "worktree" || cur.worktreeId === target.worktreeId) &&
+ (cur?.kind !== "session" || target?.kind !== "session" || cur.sessionId === target.sessionId)
+ if (same) return
+ this.activeTarget = target
+ void this.save()
+ }
+
// ---------------------------------------------------------------------------
// Worktree order
// ---------------------------------------------------------------------------
@@ -719,6 +739,7 @@ export class WorktreeStateManager {
this.reviewDiffStyle = "split"
}
this.defaultBase = data.defaultBaseBranch
+ this.activeTarget = data.activeTarget
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
if (pruned > 0 || repaired) {
if (pruned > 0) this.log(`Pruned ${pruned} orphaned sessions`)
@@ -827,6 +848,9 @@ export class WorktreeStateManager {
if (this.defaultBase) {
data.defaultBaseBranch = this.defaultBase
}
+ if (this.activeTarget) {
+ data.activeTarget = this.activeTarget
+ }
const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`
try {
diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts
index 193d77cfcb..8b81951b17 100644
--- a/packages/kilo-vscode/src/agent-manager/host.ts
+++ b/packages/kilo-vscode/src/agent-manager/host.ts
@@ -9,6 +9,7 @@
*/
import type { Session } from "@kilocode/sdk/v2/client"
+import type { ProjectRef, SessionRef, WorktreeRef } from "./project-route"
// ---------------------------------------------------------------------------
// Primitives
@@ -36,6 +37,8 @@ export interface SessionProvider {
clearSessionDirectory(id: string): void
getSessionDirectories(): ReadonlyMap
getSessionInfo?(id: string): Promise
+ /** List root sessions (no parent) whose directory exactly matches `dir`. */
+ listSessions?(dir: string): Promise
trackSession(id: string): void
refreshSessions(): void
registerSession(session: Session): void
@@ -49,6 +52,20 @@ export interface SessionProvider {
abortSessions(ids: readonly string[]): Promise
showMemory(sessionID?: string): Promise
toggleMemory(sessionID?: string): Promise
+ /** Register a project root with the shared route service. */
+ registerProjectRoute?(ref: ProjectRef, root: string, generation: number): void
+ /** Drop a project and all its session/worktree routes. */
+ unregisterProjectRoute?(projectId: string): void
+ /** Register a worktree directory under a project. */
+ registerWorktreeRoute?(ref: WorktreeRef, directory: string, generation: number): void
+ /** Register a session directory under a project (exact routing). */
+ registerSessionRoute?(ref: SessionRef, directory: string, generation: number): void
+ /** Drop one session route (keeps the raw ambiguity index consistent). */
+ unregisterSessionRoute?(ref: SessionRef): void
+ /** Whether a raw session id is known to be ambiguous across projects. */
+ isSessionRouteAmbiguous?(sessionId: string): boolean
+ /** Exact directory for a project-qualified session ref, or undefined. */
+ routeSessionDirectoryFor?(ref: SessionRef): string | undefined
dispose(): void
}
@@ -99,11 +116,34 @@ export interface Host {
openPanel(opts: {
onBeforeMessage: (msg: Record) => Promise | null>
worktreeDirectories?: () => string[]
+ /** Dynamic root directory for the panel's session provider (follows the active project). */
+ workspaceRoot?: () => string | undefined
+ projectId?: () => string | undefined
}): PanelContext
/** Get the workspace/project root path. */
workspacePath(): string | undefined
+ /** Show a folder picker and return the selected path, or undefined when cancelled. */
+ pickFolder(): Promise
+
+ /** Whether the experimental multi-project Agent Manager mode is enabled. */
+ multiProject(): boolean
+
+ /** Read the persisted additional-project registry payload. */
+ readProjects(): unknown
+
+ /** Persist the additional-project registry payload. */
+ writeProjects(value: unknown): Promise
+
+ unregisterProjectRoutes(projectId: string): void
+
+ /** Subscribe to workspace folder changes (pinned project re-derivation). */
+ onDidChangeWorkspaceFolders(cb: () => void): Disposable
+
+ /** Subscribe to multi-project flag changes. */
+ onDidChangeMultiProject(cb: (enabled: boolean) => void): Disposable
+
/** Read the user's automatic branch naming preferences. */
autoBranchNaming(): { enabled: boolean; prefix: string }
diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts
index 5675c31c85..cbbb1bffda 100644
--- a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts
+++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts
@@ -89,6 +89,11 @@ export class PRStatusBridge {
remove(worktreeId: string): void {
this.cache.delete(worktreeId)
}
+
+ reset(): void {
+ this.poller.stop()
+ this.cache.clear()
+ }
}
/** Build PRStatusPoller options that forward events through the bridge cache. */
diff --git a/packages/kilo-vscode/src/agent-manager/project-context.ts b/packages/kilo-vscode/src/agent-manager/project-context.ts
new file mode 100644
index 0000000000..e7adb12978
--- /dev/null
+++ b/packages/kilo-vscode/src/agent-manager/project-context.ts
@@ -0,0 +1,475 @@
+/**
+ * ProjectContext — immutable repository scope for Agent Manager.
+ * ProjectContexts — coordinator owning all contexts for one panel.
+ *
+ * A context owns the repository-bound services that used to be singletons on
+ * the provider: WorktreeStateManager, WorktreeManager, SetupScriptService,
+ * plus stale-worktree tracking. Services are created lazily on first access
+ * and never re-pointed at another root, so a context can never mix state
+ * between repositories.
+ *
+ * Lifecycle policy:
+ * - The pinned context is derived from the current VS Code workspace root and
+ * always exists while a folder is open. It is implicit and never persisted.
+ * - Additional projects come from the ProjectRegistry. Their contexts are
+ * created on demand (expand/select), never eagerly at panel open.
+ * - Only the active context gets full Git/PR polling; the pollers follow the
+ * active context through the provider's accessors.
+ * - Non-pinned contexts require the multi-project flag and registry trust
+ * before they can be expanded or activated. Trust is checked here, before
+ * any state load that could write git metadata.
+ */
+
+import * as fs from "fs"
+import * as path from "path"
+import { WorktreeStateManager } from "./WorktreeStateManager"
+import { WorktreeManager } from "./WorktreeManager"
+import { SetupScriptService } from "./SetupScriptService"
+import type { GitOps } from "./GitOps"
+import { canonicalizePath, projectIdFor, samePath } from "./project-paths"
+import type { ProjectSessionView } from "./project-session-view"
+import type { StoredProject } from "./project-registry"
+
+export interface ProjectContextDeps {
+ log: (msg: string) => void
+ git?: GitOps
+ exists?: (dir: string) => boolean
+ /** Factory overrides for tests. */
+ state?: (root: string, log: (msg: string) => void) => WorktreeStateManager
+ worktrees?: (root: string, log: (msg: string) => void, git?: GitOps) => WorktreeManager
+ setup?: (root: string) => SetupScriptService
+}
+
+export type ProjectLifecycle = "cold" | "initializing" | "ready" | "suspended" | "disposing" | "disposed"
+
+export interface ProjectInitResult {
+ ok: boolean
+ refsFixed: number
+ current: boolean
+}
+
+export class ProjectContext {
+ private state: WorktreeStateManager | undefined
+ private worktrees: WorktreeManager | undefined
+ private setup: SetupScriptService | undefined
+ private init: Promise | undefined
+ private last: ProjectInitResult | undefined
+ private phase: ProjectLifecycle = "cold"
+ private version = 0
+ private mutation: Promise = Promise.resolve()
+ private live = new Set()
+ private listed = 0
+ private views: readonly ProjectSessionView[] = []
+ readonly stale = new Set()
+
+ constructor(
+ readonly id: string,
+ readonly root: string,
+ readonly pinned: boolean,
+ private readonly deps: ProjectContextDeps,
+ ) {}
+
+ get lifecycle(): ProjectLifecycle {
+ return this.phase
+ }
+
+ get generation(): number {
+ return this.version
+ }
+
+ isCurrent(generation: number): boolean {
+ return this.version === generation && this.phase !== "disposing" && this.phase !== "disposed"
+ }
+
+ /** Initialize repository state exactly once per context lifetime. */
+ ensureReady(run: (generation: number) => Promise>): Promise {
+ if (this.phase === "disposed" || this.phase === "disposing") {
+ return Promise.resolve({ ok: false, refsFixed: 0, current: false })
+ }
+ if (this.phase === "ready" && this.last) return Promise.resolve(this.last)
+ if (this.phase === "suspended" && this.last) {
+ this.phase = "ready"
+ return Promise.resolve(this.last)
+ }
+ if (this.init) return this.init
+ const generation = this.version
+ this.phase = "initializing"
+ this.init = run(generation)
+ .then((result) => {
+ const current = this.isCurrent(generation)
+ const next = { ...result, current }
+ if (current) {
+ this.last = next
+ this.phase = result.ok ? "ready" : "cold"
+ }
+ return next
+ })
+ .finally(() => {
+ this.init = undefined
+ })
+ return this.init
+ }
+
+ /** Invalidate asynchronous work while keeping loaded repository state reusable. */
+ suspend(): void {
+ if (this.phase === "disposed" || this.phase === "disposing") return
+ this.version++
+ this.phase = "suspended"
+ }
+
+ /** Serialize repository mutations and pin them to this context generation. */
+ run(operation: (generation: number) => Promise): Promise {
+ const generation = this.version
+ const run = async () => {
+ if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} is no longer available.`)
+ const result = await operation(generation)
+ if (!this.isCurrent(generation)) throw new Error(`Project ${this.id} changed while the operation was running.`)
+ return result
+ }
+ const next = this.mutation.then(run, run)
+ this.mutation = next.catch(() => undefined)
+ return next
+ }
+
+ stateManager(): WorktreeStateManager {
+ this.state ??= (this.deps.state ?? ((root, log) => new WorktreeStateManager(root, log)))(this.root, (msg) =>
+ this.deps.log(`[StateManager] ${msg}`),
+ )
+ return this.state
+ }
+
+ worktreeManager(): WorktreeManager {
+ this.worktrees ??= (this.deps.worktrees ?? ((root, log, git) => new WorktreeManager(root, log, git)))(
+ this.root,
+ (msg) => this.deps.log(`[WorktreeManager] ${msg}`),
+ this.deps.git,
+ )
+ return this.worktrees
+ }
+
+ setupService(): SetupScriptService {
+ this.setup ??= (this.deps.setup ?? ((root) => new SetupScriptService(root)))(this.root)
+ return this.setup
+ }
+
+ /** Whether repository-bound services have been created. */
+ get loaded(): boolean {
+ return this.state !== undefined
+ }
+
+ /** Accessors that never create services, preserving "created?" checks. */
+ peekState(): WorktreeStateManager | undefined {
+ return this.state
+ }
+
+ peekWorktrees(): WorktreeManager | undefined {
+ return this.worktrees
+ }
+
+ peekSetup(): SetupScriptService | undefined {
+ return this.setup
+ }
+
+ setLiveSessions(ids: Iterable): void {
+ this.live = new Set(ids)
+ }
+
+ hasLiveSession(id: string): boolean {
+ return this.live.has(id)
+ }
+
+ /** Replace the cached sidebar session list and mark it freshly listed. */
+ setSessions(views: readonly ProjectSessionView[]): void {
+ this.views = views
+ this.live = new Set(views.map((view) => view.id))
+ this.listed = Date.now()
+ }
+
+ /** The last collected sidebar session list, for re-posting on fresh skips. */
+ sessions(): readonly ProjectSessionView[] {
+ return this.views
+ }
+
+ /** Force the next push to re-list from the backend. */
+ invalidateSessions(): void {
+ this.listed = 0
+ }
+
+ /** Insert or refresh one session in the cached list (creation, rename, fork). */
+ upsertSession(view: ProjectSessionView): void {
+ this.views = [view, ...this.views.filter((item) => item.id !== view.id)]
+ this.live.add(view.id)
+ }
+
+ /** Drop one session from the cached list (deletion, close). */
+ removeLiveSession(id: string): void {
+ this.views = this.views.filter((item) => item.id !== id)
+ this.live.delete(id)
+ }
+
+ /** Mark that the backend session list was just collected for this context. */
+ markSessionsListed(): void {
+ this.listed = Date.now()
+ }
+
+ /** Whether the last backend session listing is still fresh enough to reuse. */
+ sessionsListedFresh(ms: number): boolean {
+ return this.listed > 0 && Date.now() - this.listed < ms
+ }
+
+ missing(): boolean {
+ return !(this.deps.exists ?? fs.existsSync)(this.root)
+ }
+
+ async dispose(): Promise {
+ if (this.phase === "disposed") return
+ this.version++
+ this.phase = "disposing"
+ await this.init?.catch((err) => this.deps.log(`dispose: initialization failed: ${err}`))
+ await this.mutation.catch((err) => this.deps.log(`dispose: mutation failed: ${err}`))
+ await this.state?.flush().catch((err) => this.deps.log(`dispose: state flush failed: ${err}`))
+ this.live.clear()
+ this.phase = "disposed"
+ }
+}
+
+/** Serializable project description for the webview. */
+export interface ProjectSnapshot {
+ id: string
+ root: string
+ label: string
+ pinned: boolean
+ active: boolean
+ expanded: boolean
+ initialized: boolean
+ trusted: boolean
+ missing: boolean
+}
+
+interface ContextsOptions {
+ /** Current VS Code workspace root; may change when workspace folders change. */
+ workspaceRoot: () => string | undefined
+ registry: {
+ list(): StoredProject[]
+ get(id: string): StoredProject | undefined
+ }
+ /** Registry trust lookup for non-pinned projects. */
+ trusted: (id: string) => boolean
+ /** Whether the multi-project experiment is enabled. */
+ enabled: () => boolean
+ remove?: (id: string) => void
+ deps: ProjectContextDeps
+}
+
+export class ProjectContexts {
+ private readonly contexts = new Map()
+ private activeId: string | undefined
+ private readonly expanded = new Set()
+
+ constructor(private readonly opts: ContextsOptions) {}
+
+ /** The pinned workspace project, derived lazily from the current workspace root. */
+ pinned(): ProjectContext | undefined {
+ const root = this.opts.workspaceRoot()
+ if (!root) return undefined
+ const canonical = canonicalizePath(root)
+ return this.ensure(projectIdFor(canonical), canonical, true)
+ }
+
+ private ensure(id: string, root: string, pinned: boolean): ProjectContext {
+ let ctx = this.contexts.get(id)
+ if (!ctx) {
+ ctx = new ProjectContext(id, root, pinned, this.opts.deps)
+ this.contexts.set(id, ctx)
+ }
+ return ctx
+ }
+
+ /** Resolve any known project id to a context, creating it on demand. */
+ private resolveCtx(id: string): ProjectContext | undefined {
+ const existing = this.contexts.get(id)
+ if (existing) return existing
+ const pinned = this.pinned()
+ if (pinned?.id === id) return pinned
+ if (!this.opts.enabled()) return undefined
+ const stored = this.opts.registry.get(id)
+ if (!stored) return undefined
+ return this.ensure(stored.id, stored.root, false)
+ }
+
+ /** The active context. Defaults to the pinned project, or the first trusted registry project without a workspace. */
+ active(): ProjectContext | undefined {
+ if (this.activeId) return this.contexts.get(this.activeId)
+ const pinned = this.pinned()
+ if (pinned) {
+ this.activeId = pinned.id
+ this.expanded.add(pinned.id)
+ return pinned
+ }
+ if (!this.opts.enabled()) return undefined
+ const first = this.opts.registry.list().find((p) => this.opts.trusted(p.id))
+ if (!first) return undefined
+ const ctx = this.ensure(first.id, first.root, false)
+ this.activeId = ctx.id
+ this.expanded.add(ctx.id)
+ return ctx
+ }
+
+ get(id: string): ProjectContext | undefined {
+ return this.contexts.get(id)
+ }
+
+ /** The context that owns a directory: its root or one of its worktree paths. */
+ byDirectory(dir: string): ProjectContext | undefined {
+ for (const ctx of this.contexts.values()) {
+ if (samePath(ctx.root, dir)) return ctx
+ const state = ctx.peekState()
+ if (state?.getWorktrees().some((wt) => wt.path && samePath(wt.path, dir))) return ctx
+ }
+ return undefined
+ }
+
+ /** The context whose live session list contains the session. */
+ byLiveSession(id: string): ProjectContext | undefined {
+ for (const ctx of this.contexts.values()) {
+ if (ctx.hasLiveSession(id)) return ctx
+ }
+ return undefined
+ }
+
+ /** Resolve any known project id to a context without activating it. */
+ resolve(id: string): ProjectContext | undefined {
+ return this.resolveCtx(id)
+ }
+
+ /** Whether a project may be shown or initialized: known, flag-gated, and trusted. */
+ usable(id: string): ProjectContext | undefined {
+ return this.usableCtx(id)
+ }
+
+ isActive(id: string): boolean {
+ return this.active()?.id === id
+ }
+
+ isExpanded(id: string): boolean {
+ return this.expanded.has(id)
+ }
+
+ /** Make a project the active context and expand it. Returns undefined when not allowed. */
+ activate(id: string): ProjectContext | undefined {
+ const ctx = this.usableCtx(id)
+ if (!ctx) return undefined
+ this.activeId = id
+ return ctx
+ }
+
+ /** Expand a project without activating it. Returns undefined when not allowed. */
+ expand(id: string): ProjectContext | undefined {
+ const ctx = this.usableCtx(id)
+ if (!ctx) return undefined
+ this.expanded.add(id)
+ return ctx
+ }
+
+ collapse(id: string): void {
+ this.expanded.delete(id)
+ if (this.isActive(id)) return
+ this.contexts.get(id)?.suspend()
+ }
+
+ /** Return ownership to pinned Local and suspend all secondary contexts. */
+ disable(): ProjectContext | undefined {
+ const pinned = this.pinned()
+ this.activeId = pinned?.id
+ if (pinned) this.expanded.add(pinned.id)
+ for (const ctx of this.contexts.values()) {
+ if (ctx.pinned) continue
+ this.expanded.delete(ctx.id)
+ ctx.suspend()
+ }
+ return pinned
+ }
+
+ private usableCtx(id: string): ProjectContext | undefined {
+ const ctx = this.resolveCtx(id)
+ if (!ctx) return undefined
+ if (ctx.pinned) return ctx
+ if (!this.opts.enabled()) return undefined
+ if (!this.opts.trusted(id)) return undefined
+ return ctx
+ }
+
+ /** Remove a non-pinned project context. Falls back to the pinned project when it was active. */
+ async remove(id: string): Promise {
+ const ctx = this.contexts.get(id)
+ if (!ctx || ctx.pinned) return false
+ this.expanded.delete(id)
+ if (this.activeId === id) this.activeId = undefined
+ this.contexts.delete(id)
+ this.opts.remove?.(id)
+ await ctx.dispose()
+ return true
+ }
+
+ /**
+ * Re-derive the pinned project after workspace folder changes. Disposes the
+ * old pinned context so cached services can never mix two roots. Returns
+ * true when the active context may have changed.
+ */
+ syncPinned(): boolean {
+ const root = this.opts.workspaceRoot()
+ const next = root ? projectIdFor(canonicalizePath(root)) : undefined
+ const current = [...this.contexts.values()].find((ctx) => ctx.pinned)?.id
+ if (current === next) return false
+ for (const [id, ctx] of [...this.contexts]) {
+ if (!ctx.pinned) continue
+ this.contexts.delete(id)
+ this.expanded.delete(id)
+ if (this.activeId === id) this.activeId = undefined
+ this.opts.remove?.(id)
+ ctx.suspend()
+ void ctx.dispose()
+ }
+ return true
+ }
+
+ /** Serializable snapshots for the webview: pinned first, then registry order. */
+ snapshots(): ProjectSnapshot[] {
+ const out: ProjectSnapshot[] = []
+ const pinned = this.pinned()
+ if (pinned) out.push(this.snapshot(pinned, undefined))
+ if (!this.opts.enabled()) return out
+ for (const stored of this.opts.registry.list()) {
+ if (pinned?.id === stored.id) continue
+ out.push(this.snapshot(this.contexts.get(stored.id), stored))
+ }
+ return out
+ }
+
+ private snapshot(ctx: ProjectContext | undefined, stored: StoredProject | undefined): ProjectSnapshot {
+ const id = ctx?.id ?? stored!.id
+ const root = ctx?.root ?? stored!.root
+ const pinned = ctx?.pinned ?? false
+ return {
+ id,
+ root,
+ label: stored?.label || path.basename(root) || root,
+ pinned,
+ active: this.isActive(id),
+ expanded: this.isExpanded(id),
+ initialized: ctx?.loaded ?? false,
+ trusted: pinned || (stored?.trusted ?? false),
+ missing: ctx ? ctx.missing() : !(this.opts.deps.exists ?? fs.existsSync)(root),
+ }
+ }
+
+ async dispose(): Promise {
+ for (const ctx of this.contexts.values()) {
+ this.opts.remove?.(ctx.id)
+ await ctx.dispose()
+ }
+ this.contexts.clear()
+ this.expanded.clear()
+ this.activeId = undefined
+ }
+}
diff --git a/packages/kilo-vscode/src/agent-manager/project-init.ts b/packages/kilo-vscode/src/agent-manager/project-init.ts
new file mode 100644
index 0000000000..402773ddd6
--- /dev/null
+++ b/packages/kilo-vscode/src/agent-manager/project-init.ts
@@ -0,0 +1,237 @@
+/**
+ * Repository-local state initialization for one Agent Manager project context.
+ *
+ * Extracted from AgentManagerProvider so the same sequence runs for the
+ * active project (full panel wiring follows) and for expanded background
+ * projects (state push only). Ordering matters: the git exclude update must
+ * land before the persisted state file is read.
+ */
+
+import { restoreWorktrees } from "./state-recovery"
+import type { ProjectContext, ProjectInitResult } from "./project-context"
+import type { Session } from "@kilocode/sdk/v2/client"
+import type { ProjectRef, SessionRef, WorktreeRef } from "./project-route"
+import { sessionToWebview } from "../kilo-provider-utils"
+
+export type { ProjectSessionView } from "./project-session-view"
+import type { ProjectSessionView } from "./project-session-view"
+
+/**
+ * Subset of {@link SessionProvider} needed to discover and register live
+ * project sessions by directory. Kept narrow so project-init stays testable
+ * without importing the full provider. Route registration methods are
+ * optional so tests and non-Agent-Manager callers can omit them.
+ */
+export interface ProjectSessionListing {
+ listSessions?(dir: string): Promise
+ setSessionDirectory(id: string, directory: string): void
+ /** Register a project root with the shared route service. */
+ registerProjectRoute?(ref: ProjectRef, root: string, generation: number): void
+ /** Drop a project and all its session/worktree routes. */
+ unregisterProjectRoute?(projectId: string): void
+ /** Register a worktree directory under a project. */
+ registerWorktreeRoute?(ref: WorktreeRef, directory: string, generation: number): void
+ /** Register a session directory under a project (exact routing). */
+ registerSessionRoute?(ref: SessionRef, directory: string, generation: number): void
+ /** Drop one session route (keeps the raw ambiguity index consistent). */
+ unregisterSessionRoute?(ref: SessionRef): void
+}
+
+/**
+ * Register the project root, every worktree directory, and every persisted
+ * managed session with the shared route service. Registered routes let the
+ * KiloProvider resolve project-qualified session refs to exact directories
+ * and detect ambiguous raw session ids across projects. Safe to call when the
+ * listing has no route methods: registration is simply skipped.
+ */
+function registerProjectRoutes(ctx: ProjectContext, sessions: ProjectSessionListing | undefined): void {
+ if (!sessions) return
+ const state = ctx.peekState()
+ sessions.registerProjectRoute?.({ projectId: ctx.id }, ctx.root, ctx.generation)
+ if (state) {
+ for (const wt of state.getWorktrees()) {
+ if (!wt.path) continue
+ sessions.registerWorktreeRoute?.({ projectId: ctx.id, worktreeId: wt.id }, wt.path, ctx.generation)
+ }
+ }
+}
+
+/** Drop all routes for a project (e.g. when it is removed or disposed). */
+export function unregisterProjectRoutes(ctx: ProjectContext, sessions: ProjectSessionListing | undefined): void {
+ if (!sessions) return
+ sessions.unregisterProjectRoute?.(ctx.id)
+}
+
+export async function initContextState(
+ ctx: ProjectContext,
+ log: (...args: unknown[]) => void,
+): Promise {
+ return ctx.ensureReady(async (generation) => {
+ const manager = ctx.worktreeManager()
+ const state = ctx.stateManager()
+ await manager.ensureGitExclude().catch((err) => log("Failed to update git exclude:", err))
+ if (!ctx.isCurrent(generation)) return { ok: false, refsFixed: 0 }
+ const loaded = await state.load()
+ if (!ctx.isCurrent(generation)) return { ok: false, refsFixed: 0 }
+ manager.cleanupOrphanedTempDirs()
+
+ if (loaded.status === "failed" && !(await state.prepareRecovery())) {
+ return { ok: false, refsFixed: 0 }
+ }
+ if (!ctx.isCurrent(generation)) return { ok: false, refsFixed: 0 }
+
+ const infos = await manager.discoverWorktrees().catch((err) => {
+ log("Failed to discover worktrees during state recovery:", err)
+ return []
+ })
+ if (!ctx.isCurrent(generation)) return { ok: false, refsFixed: 0 }
+ if (infos.length > 0) {
+ const result = restoreWorktrees(state, infos)
+ if (result.worktrees > 0 || result.sessions > 0) {
+ log(`Recovered ${result.worktrees} worktree(s) and ${result.sessions} session(s) from disk`)
+ await state.flush()
+ }
+ }
+ return { ok: true, refsFixed: loaded.refsFixed }
+ })
+}
+
+/** Register explicit Local/worktree routes for every persisted project session. */
+export function registerProjectSessions(
+ ctx: ProjectContext,
+ sessions:
+ | ({
+ setSessionDirectory(id: string, directory: string): void
+ trackSession(id: string): void
+ } & Partial)
+ | undefined,
+): void {
+ const state = ctx.peekState()
+ if (!state || !sessions) return
+ registerProjectRoutes(ctx, sessions)
+ for (const session of state.getSessions()) {
+ const worktree = session.worktreeId ? state.getWorktree(session.worktreeId) : undefined
+ const dir = worktree?.path ?? ctx.root
+ sessions.setSessionDirectory(session.id, dir)
+ sessions.trackSession(session.id)
+ sessions.registerSessionRoute?.({ projectId: ctx.id, sessionId: session.id }, dir, ctx.generation)
+ }
+}
+
+/**
+ * Directories whose root sessions belong to this project: exactly {@link ProjectContext.root}
+ * plus the paths of every worktree tracked by this context's state. No active
+ * workspace root is ever mixed in, so sessions from unrelated projects cannot
+ * leak into the listing.
+ */
+export function projectDirectories(ctx: ProjectContext): string[] {
+ const state = ctx.peekState()
+ const dirs = new Set([ctx.root])
+ if (state) for (const wt of state.getWorktrees()) if (wt.path) dirs.add(wt.path)
+ return [...dirs]
+}
+
+/**
+ * Collect live root sessions for this project by listing exactly {@link ProjectContext.root}
+ * and each of its worktree directories through the SessionProvider. Each
+ * returned session is registered with the directory it was listed from before
+ * being included, so downstream backend requests route to the correct
+ * directory-scoped Instance. Managed placement is preserved separately by
+ * {@link registerProjectSessions}, which runs first and pins persisted managed
+ * sessions to their worktree paths; re-registering a managed session here with
+ * the same worktree path is a no-op.
+ *
+ * A failed directory listing (e.g. backend not connected, missing worktree)
+ * resolves to [] and never erases results from the other directories, so one
+ * failure cannot blank the whole project.
+ */
+export async function collectProjectSessions(
+ ctx: ProjectContext,
+ sessions: ProjectSessionListing | undefined,
+): Promise {
+ if (!sessions?.listSessions) {
+ ctx.setLiveSessions([])
+ return []
+ }
+ const generation = ctx.generation
+ const list = sessions.listSessions
+ const state = ctx.peekState()
+ // Register the project root and worktree directories before listing so the
+ // route service can disambiguate sessions discovered in this project even
+ // when a raw session id collides with one in another project.
+ registerProjectRoutes(ctx, sessions)
+ const dirs = [
+ { dir: ctx.root, worktreeId: null },
+ ...(state
+ ?.getWorktrees()
+ .filter((wt) => !!wt.path)
+ .map((wt) => ({ dir: wt.path, worktreeId: wt.id })) ?? []),
+ ].filter((entry, index, all) => all.findIndex((item) => item.dir === entry.dir) === index)
+ const byDir = await Promise.all(
+ dirs.map(async (entry) => {
+ const items = await list(entry.dir).catch((err) => {
+ console.warn(`[Kilo New] Agent Manager: listing sessions for ${entry.dir} failed:`, err)
+ return [] as Session[]
+ })
+ return { ...entry, items }
+ }),
+ )
+ if (ctx.lifecycle === "disposed" || ctx.lifecycle === "disposing") return []
+ if (!ctx.isCurrent(generation)) return []
+ const seen = new Set()
+ const out: ProjectSessionView[] = []
+ for (const { dir, worktreeId, items } of byDir) {
+ for (const s of items) {
+ if (seen.has(s.id)) continue
+ seen.add(s.id)
+ sessions.setSessionDirectory(s.id, dir)
+ // Register an exact route for every live session. Re-registering a
+ // managed session (already routed by registerProjectSessions) with the
+ // same directory is a no-op; a different directory would mean the
+ // session moved worktrees, and the latest registration wins.
+ sessions.registerSessionRoute?.({ projectId: ctx.id, sessionId: s.id }, dir, generation)
+ if (worktreeId) {
+ sessions.registerWorktreeRoute?.({ projectId: ctx.id, worktreeId }, dir, generation)
+ }
+ out.push({ ...sessionToWebview(s), worktreeId })
+ }
+ }
+ return out
+}
+
+export async function pushProjectSessions(
+ ctx: ProjectContext,
+ sessions: ProjectSessionListing | undefined,
+ post: (message: { type: "agentManager.projectSessions"; projectId: string; sessions: ProjectSessionView[] }) => void,
+): Promise {
+ const gone = () => ctx.lifecycle === "disposed" || ctx.lifecycle === "disposing"
+ if (gone()) return
+ // Skip the backend round trips when the listing is still fresh, but always
+ // re-post the cached list: the webview may have mounted after the last push
+ // and has no other way to learn this project's sessions.
+ if (ctx.sessionsListedFresh(2000)) {
+ post({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] })
+ return
+ }
+ const views = await collectProjectSessions(ctx, sessions)
+ if (gone()) return
+ ctx.setSessions(views)
+ post({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...views] })
+}
+
+/**
+ * Fast reactivation of an already-initialized context: re-register routes and
+ * push in-memory state without re-running the full initialization sequence
+ * (state load, worktree discovery, session listing, prompt recovery). Returns
+ * false when the context is not ready and the caller must run the full init.
+ */
+export function reactivateProject(
+ ctx: ProjectContext,
+ sessions: Parameters[1],
+ pushState: (ctx: ProjectContext) => void,
+): boolean {
+ if (ctx.lifecycle !== "ready" || !ctx.peekState()) return false
+ registerProjectSessions(ctx, sessions)
+ pushState(ctx)
+ return true
+}
diff --git a/packages/kilo-vscode/src/agent-manager/project-messages.ts b/packages/kilo-vscode/src/agent-manager/project-messages.ts
new file mode 100644
index 0000000000..1716186fcb
--- /dev/null
+++ b/packages/kilo-vscode/src/agent-manager/project-messages.ts
@@ -0,0 +1,201 @@
+/**
+ * Project message handlers for the Agent Manager multi-project protocol.
+ *
+ * Extracted from AgentManagerProvider (file-size cap) and kept free of VS Code
+ * imports so the flows are unit-testable. All handlers fail closed: unknown
+ * projects, untrusted projects, and disabled experiments leave state untouched.
+ */
+
+import simpleGit from "simple-git"
+import type { AgentManagerInMessage } from "./types"
+import type { ProjectRegistry } from "./project-registry"
+import type { ProjectContext, ProjectContexts, ProjectInitResult } from "./project-context"
+import { projectIdFor, resolveGitRoot, samePath } from "./project-paths"
+import type { SidebarTarget } from "./project-route"
+
+export interface ProjectMessageDeps {
+ registry: ProjectRegistry
+ contexts: ProjectContexts
+ /** Whether the multi-project experiment is enabled. */
+ enabled: () => boolean
+ /** Show a folder picker; resolves undefined when cancelled. */
+ pickFolder: () => Promise
+ /** Re-initialize provider state for a freshly activated context. */
+ activate: (ctx: ProjectContext) => void
+ /** Initialize an expanded background context and push its state. */
+ expand: (ctx: ProjectContext) => void
+ /** Push the current project snapshots to the webview. */
+ push: () => void
+ /** Acknowledge an atomically validated sidebar selection. */
+ selected: (target: SidebarTarget) => void
+ /** Show a user-facing error. */
+ error: (message: string) => void
+ /** Ensure a context's repository state is ready (no-op once initialized). */
+ ready: (ctx: ProjectContext) => Promise
+ log: (...args: unknown[]) => void
+}
+
+/** Handle a project-management message. Returns true when the message was consumed. */
+export async function handleProjectMessage(m: AgentManagerInMessage, deps: ProjectMessageDeps): Promise {
+ if (m.type === "agentManager.requestProjects") {
+ deps.push()
+ return true
+ }
+ if (m.type === "agentManager.addProject") {
+ await addProject(deps)
+ return true
+ }
+ if (m.type === "agentManager.removeProject") {
+ await removeProject(m.projectId, deps)
+ return true
+ }
+ if (m.type === "agentManager.selectProject") {
+ selectProject(m.projectId, deps)
+ return true
+ }
+ if (m.type === "agentManager.activateSelection") {
+ await activateSelection(m.target, deps, m.restore === true)
+ return true
+ }
+ if (m.type === "agentManager.rememberTarget") {
+ rememberTarget(m.projectId, m.target, deps)
+ return true
+ }
+ if (m.type === "agentManager.setProjectExpanded") {
+ await setExpanded(m.projectId, m.expanded, deps)
+ return true
+ }
+ if (m.type === "agentManager.trustProject") {
+ await trustProject(m.projectId, deps)
+ return true
+ }
+ return false
+}
+
+async function activateSelection(requested: SidebarTarget, deps: ProjectMessageDeps, restore = false): Promise {
+ if (disabled(deps)) return
+ const ctx = deps.contexts.resolve(requested.projectId)
+ if (!ctx || !deps.contexts.usable(requested.projectId)) {
+ deps.error("The project is unavailable. Trust it first or check that the repository still exists.")
+ return
+ }
+ const result = await deps.ready(ctx)
+ if (!result.current || !result.ok) {
+ deps.error("The project is not ready yet. Expand it before selecting a worktree or session.")
+ deps.push()
+ return
+ }
+ const state = ctx.peekState()
+ // Restoring a project returns the user to their persisted target for it.
+ const persisted = restore ? state?.getActiveTarget() : undefined
+ const target = persisted?.projectId === requested.projectId ? persisted : requested
+ // A missing target is not actionable: the user clicked a sidebar row the
+ // extension itself offered, or the persisted restore target went stale.
+ // Fall back to the project's local context instead of an error toast.
+ if (target.kind === "worktree" && !state?.getWorktree(target.worktreeId)) {
+ deps.log(`selection target worktree ${target.worktreeId} is gone, falling back to local`)
+ return finish({ projectId: target.projectId, kind: "local" }, deps)
+ }
+ if (target.kind === "session" && !state?.getSession(target.sessionId) && !ctx.hasLiveSession(target.sessionId)) {
+ deps.log(`selection target session ${target.sessionId} is gone, falling back to local`)
+ return finish({ projectId: target.projectId, kind: "local" }, deps)
+ }
+ finish(target, deps)
+}
+
+/** Commit the active project, persist the target, and acknowledge the selection. */
+function finish(target: SidebarTarget, deps: ProjectMessageDeps): void {
+ const previous = deps.contexts.active()?.id
+ const activated = deps.contexts.activate(target.projectId)
+ if (!activated) {
+ deps.error("The project is unavailable. Trust it first or check that the repository still exists.")
+ return
+ }
+ activated.peekState()?.setActiveTarget(target)
+ if (previous !== activated.id) deps.activate(activated)
+ deps.push()
+ deps.selected(target)
+}
+
+/** Persist the webview's current selection without activating or validating anything. */
+function rememberTarget(projectId: string, target: SidebarTarget, deps: ProjectMessageDeps): void {
+ if (target.projectId !== projectId) return
+ const state = deps.contexts.get(projectId)?.peekState()
+ if (!state) return
+ // Never persist a target the project does not have: the webview can race a
+ // project switch and still hold the previous project's selection.
+ if (target.kind === "worktree" && !state.getWorktree(target.worktreeId)) return
+ if (target.kind === "session" && !state.getSession(target.sessionId)) return
+ state.setActiveTarget(target)
+}
+
+function disabled(deps: ProjectMessageDeps): boolean {
+ if (deps.enabled()) return false
+ deps.error("Multi-project Agent Manager is disabled. Enable it in Kilo Settings > Experimental to add projects.")
+ return true
+}
+
+async function addProject(deps: ProjectMessageDeps): Promise {
+ if (disabled(deps)) return
+ const dir = await deps.pickFolder()
+ if (!dir) return
+ const root = await resolveGitRoot(dir, (cwd) => simpleGit(cwd).revparse(["--show-toplevel"]))
+ if (!root) {
+ deps.error("The selected folder is not inside a Git repository.")
+ return
+ }
+ const pinned = deps.contexts.pinned()
+ if (pinned && samePath(pinned.root, root)) {
+ deps.error("That repository is already the workspace project.")
+ return
+ }
+ const id = projectIdFor(root)
+ if (deps.registry.get(id)) {
+ deps.error("That repository is already registered as a project.")
+ return
+ }
+ try {
+ await deps.registry.add({ id, root })
+ } catch (err) {
+ deps.log("addProject: registry write failed:", err)
+ deps.error("Failed to save the project. See the Agent Manager output for details.")
+ return
+ }
+ deps.log(`addProject: registered ${root}`)
+ deps.push()
+}
+
+async function removeProject(id: string, deps: ProjectMessageDeps): Promise {
+ if (disabled(deps)) return
+ await deps.contexts.remove(id)
+ await deps.registry.remove(id)
+ deps.push()
+}
+
+function selectProject(id: string, deps: ProjectMessageDeps): void {
+ if (disabled(deps)) return
+ const ctx = deps.contexts.activate(id)
+ if (!ctx) {
+ deps.error("The project is unavailable. Trust it first or check that the repository still exists.")
+ deps.push()
+ return
+ }
+ deps.activate(ctx)
+ deps.push()
+}
+
+async function setExpanded(id: string, expanded: boolean, deps: ProjectMessageDeps): Promise {
+ if (disabled(deps)) return
+ if (expanded) {
+ const ctx = deps.contexts.expand(id)
+ if (ctx) deps.expand(ctx)
+ }
+ if (!expanded) deps.contexts.collapse(id)
+ deps.push()
+}
+
+async function trustProject(id: string, deps: ProjectMessageDeps): Promise {
+ if (disabled(deps)) return
+ await deps.registry.setTrusted(id, true)
+ deps.push()
+}
diff --git a/packages/kilo-vscode/src/agent-manager/project-paths.ts b/packages/kilo-vscode/src/agent-manager/project-paths.ts
new file mode 100644
index 0000000000..c2860881ec
--- /dev/null
+++ b/packages/kilo-vscode/src/agent-manager/project-paths.ts
@@ -0,0 +1,89 @@
+/**
+ * Project path canonicalization for the Agent Manager project registry.
+ *
+ * Project identity is anchored to the canonical Git top-level path so that
+ * symlink aliases (e.g. /tmp -> /private/tmp) and case variants cannot
+ * register the same repository twice or route sessions to the wrong project.
+ */
+
+import * as fs from "fs"
+import * as path from "path"
+import { createHash } from "crypto"
+import { normalizePath } from "./git-import"
+
+/** Resolve symlinks and normalize separators. Falls back to lexical resolution when the path does not exist. */
+export function canonicalizePath(dir: string): string {
+ const resolved = path.resolve(dir)
+ try {
+ return normalizePath(fs.realpathSync.native(resolved))
+ } catch {
+ return normalizePath(resolved)
+ }
+}
+
+/** Compare two canonical paths. Case-insensitive filesystems compare folded. */
+export function samePath(a: string, b: string, platform: NodeJS.Platform = process.platform): boolean {
+ if (platform === "darwin" || platform === "win32") return a.toLowerCase() === b.toLowerCase()
+ return a === b
+}
+
+/**
+ * Resolve the branch for the tracked worktree whose canonical path matches `dir`.
+ *
+ * `git worktree list --porcelain` realpath-resolves worktree registration, so
+ * tracked keys are canonical. The probe path is canonicalized the same way via
+ * `canonicalizePath` and compared with `samePath`, so symlink aliases such as
+ * /tmp -> /private/tmp and case variants on darwin/win32 cannot mark a real
+ * worktree missing. Tracked keys that no longer exist fall back to lexical
+ * normalization, preserving missing-path handling. Returns undefined when no
+ * tracked entry matches.
+ */
+export function findTrackedBranch(
+ tracked: Map,
+ dir: string,
+ platform: NodeJS.Platform = process.platform,
+): string | undefined {
+ const canonical = canonicalizePath(dir)
+ for (const [key, branch] of tracked) {
+ if (samePath(canonicalizePath(key), canonical, platform)) return branch
+ }
+ return undefined
+}
+
+/** Deterministic project id derived from the canonical root. Stable across restarts and panel recreations. */
+export function projectIdFor(root: string): string {
+ return `prj-${createHash("sha1").update(root).digest("hex").slice(0, 12)}`
+}
+
+/** Resolve the canonical Git top-level for a directory, or undefined when it is not inside a repository. */
+export async function resolveGitRoot(
+ dir: string,
+ revparse: (cwd: string) => Promise,
+): Promise {
+ const top = await revparse(dir).catch(() => undefined)
+ if (!top) return undefined
+ return canonicalizePath(top.trim())
+}
+
+/** Resolve linked worktrees to the primary checkout so project-local state is shared by the repository. */
+export async function resolveProjectRoot(
+ dir: string,
+ git: (cwd: string, args: string[]) => Promise,
+): Promise {
+ const run = (args: string[]) =>
+ Promise.resolve()
+ .then(() => git(dir, args))
+ .catch(() => undefined)
+ const top = await run(["--path-format=absolute", "--show-toplevel"])
+ if (!top) return undefined
+ const root = canonicalizePath(top.trim())
+ const [gitdir, common] = await Promise.all([
+ run(["--path-format=absolute", "--git-dir"]),
+ run(["--path-format=absolute", "--git-common-dir"]),
+ ])
+ if (!gitdir || !common) return root
+ if (samePath(canonicalizePath(gitdir.trim()), canonicalizePath(common.trim()))) return root
+ const listing = await run(["worktree", "list", "--porcelain", "-z"])
+ const first = listing?.split("\0").find((field) => field.startsWith("worktree "))
+ return first ? canonicalizePath(first.slice("worktree ".length)) : root
+}
diff --git a/packages/kilo-vscode/src/agent-manager/project-pollers.ts b/packages/kilo-vscode/src/agent-manager/project-pollers.ts
new file mode 100644
index 0000000000..7d79a51fa2
--- /dev/null
+++ b/packages/kilo-vscode/src/agent-manager/project-pollers.ts
@@ -0,0 +1,178 @@
+/**
+ * Per-project stats/PR pollers for expanded background projects, plus the
+ * factory for the provider's poller trio (extracted from AgentManagerProvider
+ * for its file-size cap).
+ *
+ * The active project is served by the singleton `stats`/`pr` pollers (their
+ * getters follow the active context). `ProjectPollers` creates one poller pair
+ * per expanded non-active project so every visible project accordion receives
+ * live worktree stats, local stats, and PR statuses, each tagged with the
+ * owning projectId. VS Code-free and unit-testable: the poller pair factory is
+ * injectable.
+ */
+
+import type { GitOps } from "./GitOps"
+import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
+import { diffSummary } from "./local-diff"
+import { PRStatusBridge } from "./pr-status-bridge"
+import type { PRStatus } from "./types"
+import type { ProjectContext, ProjectContexts } from "./project-context"
+import type { Semaphore } from "./semaphore"
+import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types"
+import type { WorktreeStateManager } from "./WorktreeStateManager"
+
+export interface PollerPair {
+ stats: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void }
+ pr: { poller: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void } }
+}
+
+export type StatsOutMessage =
+ | { type: "agentManager.worktreeStats"; projectId?: string; stats: WorktreeStats[] }
+ | { type: "agentManager.localStats"; projectId?: string; stats: LocalStats }
+ | { type: "agentManager.prStatus"; projectId?: string; worktreeId: string; pr: PRStatus | null }
+
+type StatsMessage = Extract
+
+interface PollerDeps {
+ git: GitOps
+ semaphore: Semaphore
+ localDiff: (dir: string, base: string) => Promise
+ post: (msg: StatsOutMessage) => void
+ openExternal: (url: string) => void
+ visible: () => boolean
+ log: (...args: unknown[]) => void
+}
+
+/** Create the real poller pair for one project context. */
+function createPollerPair(ctx: ProjectContext, deps: PollerDeps): PollerPair {
+ const state = () => ctx.peekState()
+ const stats = new GitStatsPoller({
+ getWorktrees: () => state()?.getWorktrees() ?? [],
+ getWorkspaceRoot: () => ctx.root,
+ localDiff: deps.localDiff,
+ git: deps.git,
+ semaphore: deps.semaphore,
+ log: deps.log,
+ onStats: (stats) => deps.post({ type: "agentManager.worktreeStats", projectId: ctx.id, stats }),
+ onLocalStats: (stats) => deps.post({ type: "agentManager.localStats", projectId: ctx.id, stats }),
+ })
+ const pr = PRStatusBridge.create({
+ getWorktrees: () => state()?.getWorktrees() ?? [],
+ getWorkspaceRoot: () => ctx.root,
+ postToWebview: (m) => deps.post({ ...m, projectId: ctx.id } as StatsOutMessage),
+ updateWorktreePR: (id, n, u, s) => state()?.updateWorktreePR(id, n, u, s),
+ hasPersistedPR: (id) => !!state()?.getWorktree(id)?.prNumber,
+ openExternal: deps.openExternal,
+ log: deps.log,
+ semaphore: deps.semaphore,
+ })
+ return { stats, pr }
+}
+
+export class ProjectPollers {
+ private readonly pollers = new Map()
+
+ constructor(
+ private readonly deps: PollerDeps,
+ private readonly create: (ctx: ProjectContext, deps: PollerDeps) => PollerPair = createPollerPair,
+ ) {}
+
+ /**
+ * Reconcile pollers with the current expanded set: start pollers for
+ * expanded, trusted, non-active projects whose state is initialized, and
+ * stop pollers for projects that were collapsed, removed, or activated.
+ */
+ sync(contexts: ProjectContexts): void {
+ const wanted = new Set()
+ for (const snap of contexts.snapshots()) {
+ if (snap.active || !snap.expanded || !snap.trusted || snap.missing) continue
+ const ctx = contexts.get(snap.id)
+ if (!ctx?.peekState()) continue
+ wanted.add(snap.id)
+ if (this.pollers.has(snap.id)) continue
+ const pair = this.create(ctx, this.deps)
+ pair.stats.setVisible(this.deps.visible())
+ pair.pr.poller.setVisible(this.deps.visible())
+ pair.stats.setEnabled(true)
+ pair.pr.poller.setEnabled(true)
+ this.pollers.set(snap.id, pair)
+ }
+ for (const [id, pair] of [...this.pollers]) {
+ if (wanted.has(id)) continue
+ pair.stats.stop()
+ pair.pr.poller.stop()
+ this.pollers.delete(id)
+ }
+ }
+
+ setVisible(visible: boolean): void {
+ for (const pair of this.pollers.values()) {
+ pair.stats.setVisible(visible)
+ pair.pr.poller.setVisible(visible)
+ }
+ }
+
+ dispose(): void {
+ for (const pair of this.pollers.values()) {
+ pair.stats.stop()
+ pair.pr.poller.stop()
+ }
+ this.pollers.clear()
+ }
+}
+
+/** Create the provider's poller trio: singleton active-project pollers plus per-project background pollers. */
+export function createPollers(opts: {
+ git: GitOps
+ semaphore: Semaphore
+ state: () => WorktreeStateManager | undefined
+ root: () => string | undefined
+ activeId: () => string | undefined
+ visible: () => boolean
+ post: (msg: AgentManagerOutMessage) => void
+ cache: (msg: StatsMessage) => void
+ presence: (result: WorktreePresenceResult) => void
+ openExternal: (url: string) => void
+ log: (...args: unknown[]) => void
+}): { stats: GitStatsPoller; pr: PRStatusBridge; projects: ProjectPollers } {
+ const localDiff = (dir: string, base: string) => diffSummary(opts.git, dir, base, opts.log)
+ const stats = new GitStatsPoller({
+ getWorktrees: () => opts.state()?.getWorktrees() ?? [],
+ getWorkspaceRoot: opts.root,
+ localDiff,
+ semaphore: opts.semaphore,
+ onStats: (stats) => {
+ const msg = { type: "agentManager.worktreeStats" as const, projectId: opts.activeId(), stats }
+ opts.cache(msg)
+ opts.post(msg)
+ },
+ onLocalStats: (stats) => {
+ const msg = { type: "agentManager.localStats" as const, projectId: opts.activeId(), stats }
+ opts.cache(msg)
+ opts.post(msg)
+ },
+ onWorktreePresence: opts.presence,
+ log: opts.log,
+ git: opts.git,
+ })
+ const pr = PRStatusBridge.create({
+ getWorktrees: () => opts.state()?.getWorktrees() ?? [],
+ getWorkspaceRoot: opts.root,
+ postToWebview: (m) => opts.post(m.type === "agentManager.prStatus" ? { ...m, projectId: opts.activeId() } : m),
+ updateWorktreePR: (id, n, u, s) => opts.state()?.updateWorktreePR(id, n, u, s),
+ hasPersistedPR: (id) => !!opts.state()?.getWorktree(id)?.prNumber,
+ openExternal: opts.openExternal,
+ log: opts.log,
+ semaphore: opts.semaphore,
+ })
+ const projects = new ProjectPollers({
+ git: opts.git,
+ semaphore: opts.semaphore,
+ localDiff,
+ post: opts.post,
+ openExternal: opts.openExternal,
+ visible: opts.visible,
+ log: opts.log,
+ })
+ return { stats, pr, projects }
+}
diff --git a/packages/kilo-vscode/src/agent-manager/project-registry.ts b/packages/kilo-vscode/src/agent-manager/project-registry.ts
new file mode 100644
index 0000000000..9b11a11513
--- /dev/null
+++ b/packages/kilo-vscode/src/agent-manager/project-registry.ts
@@ -0,0 +1,177 @@
+/**
+ * ProjectRegistry — global catalog of additional Agent Manager projects.
+ *
+ * The registry persists only *additional* projects: repositories the user
+ * explicitly added through the Agent Manager project picker. The pinned
+ * default project is always derived from the current VS Code workspace at
+ * runtime and is never stored here.
+ *
+ * Storage is injected so the registry stays free of VS Code imports and can
+ * be unit-tested with an in-memory store. The file is versioned; corrupt or
+ * foreign content fails closed to an empty catalog and is repaired by the
+ * next mutation.
+ *
+ * Concurrency model: every mutation is funneled through a single in-instance
+ * queue, and each mutation re-reads storage immediately before applying its
+ * change. That makes a mutation atomic with respect to other mutations on the
+ * same instance and lets it merge with the latest persisted state even when
+ * the in-memory cache is stale (another window wrote in the meantime). The
+ * cache is reassigned only after the write succeeds, so a failed write never
+ * leaves memory ahead of storage. There is no cross-process lock by design.
+ */
+
+export interface StoredProject {
+ /** Deterministic id from projectIdFor(root). */
+ id: string
+ /** Canonical Git top-level path. */
+ root: string
+ /** Optional user-facing display name. */
+ label?: string
+ order: number
+ /** Whether project-controlled scripts may execute for this project. */
+ trusted: boolean
+ addedAt: string
+}
+
+interface RegistryFile {
+ version: 1
+ projects: StoredProject[]
+}
+
+export interface RegistryStorage {
+ read(): unknown
+ write(value: unknown): Promise | void
+}
+
+const VERSION = 1
+
+function valid(entry: unknown): entry is StoredProject {
+ if (!entry || typeof entry !== "object") return false
+ const e = entry as Record
+ return (
+ typeof e.id === "string" &&
+ typeof e.root === "string" &&
+ typeof e.order === "number" &&
+ typeof e.trusted === "boolean" &&
+ typeof e.addedAt === "string"
+ )
+}
+
+function parse(raw: unknown, log: (msg: string) => void): StoredProject[] {
+ if (!raw || typeof raw !== "object") return []
+ const file = raw as Partial
+ if (file.version !== VERSION || !Array.isArray(file.projects)) {
+ if (file.version !== undefined) log("project registry has an unsupported shape, starting empty")
+ return []
+ }
+ const seen = new Set()
+ const out: StoredProject[] = []
+ for (const entry of file.projects) {
+ if (!valid(entry)) continue
+ if (seen.has(entry.id)) continue
+ seen.add(entry.id)
+ out.push(entry)
+ }
+ return out.sort((a, b) => a.order - b.order)
+}
+
+export class ProjectRegistry {
+ private projects: StoredProject[] | undefined
+ private queue: Promise = Promise.resolve()
+
+ constructor(
+ private readonly storage: RegistryStorage,
+ private readonly log: (msg: string) => void = () => {},
+ ) {}
+
+ private load(): StoredProject[] {
+ this.projects ??= parse(this.storage.read(), this.log)
+ return this.projects
+ }
+
+ /** Fresh re-read + validate/dedupe/sort, used by every mutation. */
+ private fresh(): StoredProject[] {
+ return parse(this.storage.read(), this.log)
+ }
+
+ /** Persist the next catalog and update the cache only after the write succeeds. */
+ private async write(next: StoredProject[]): Promise {
+ const file: RegistryFile = { version: VERSION, projects: next }
+ await this.storage.write(file)
+ this.projects = next
+ }
+
+ /** Serialize mutations within this instance; a failed mutation does not poison the queue. */
+ private run(fn: () => Promise): Promise {
+ const result = this.queue.catch(() => {}).then(fn)
+ this.queue = result.then(
+ () => {},
+ () => {},
+ )
+ return result
+ }
+
+ list(): StoredProject[] {
+ return [...this.load()]
+ }
+
+ get(id: string): StoredProject | undefined {
+ return this.load().find((p) => p.id === id)
+ }
+
+ /** Register an additional project. Throws when the id is already registered. */
+ add(input: { id: string; root: string; label?: string }): Promise {
+ return this.run(() => this.doAdd(input))
+ }
+
+ private async doAdd(input: { id: string; root: string; label?: string }): Promise {
+ const current = this.fresh()
+ if (current.find((p) => p.id === input.id)) throw new Error("That repository is already registered as a project.")
+ const order = current.reduce((max, p) => Math.max(max, p.order), 0) + 1
+ const project: StoredProject = {
+ id: input.id,
+ root: input.root,
+ label: input.label,
+ order,
+ trusted: false,
+ addedAt: new Date().toISOString(),
+ }
+ await this.write([...current, project])
+ return project
+ }
+
+ /** Remove a project from the catalog. Never deletes repository data. */
+ remove(id: string): Promise {
+ return this.run(() => this.doRemove(id))
+ }
+
+ private async doRemove(id: string): Promise {
+ const current = this.fresh()
+ const next = current.filter((p) => p.id !== id)
+ if (next.length === current.length) return false
+ await this.write(next)
+ return true
+ }
+
+ setTrusted(id: string, trusted: boolean): Promise {
+ return this.run(() => this.doSetTrusted(id, trusted))
+ }
+
+ private async doSetTrusted(id: string, trusted: boolean): Promise {
+ const current = this.fresh()
+ if (!current.find((p) => p.id === id)) return false
+ await this.write(current.map((p) => (p.id === id ? { ...p, trusted } : p)))
+ return true
+ }
+
+ setLabel(id: string, label: string | undefined): Promise {
+ return this.run(() => this.doSetLabel(id, label))
+ }
+
+ private async doSetLabel(id: string, label: string | undefined): Promise {
+ const current = this.fresh()
+ if (!current.find((p) => p.id === id)) return false
+ await this.write(current.map((p) => (p.id === id ? { ...p, label } : p)))
+ return true
+ }
+}
diff --git a/packages/kilo-vscode/src/agent-manager/project-route.ts b/packages/kilo-vscode/src/agent-manager/project-route.ts
new file mode 100644
index 0000000000..8504e65227
--- /dev/null
+++ b/packages/kilo-vscode/src/agent-manager/project-route.ts
@@ -0,0 +1,197 @@
+/** Strict project/resource ownership for Agent Manager multi-project routing. */
+
+export interface ProjectRef {
+ projectId: string
+}
+
+export interface WorktreeRef extends ProjectRef {
+ worktreeId: string
+}
+
+export interface SessionRef extends ProjectRef {
+ sessionId: string
+}
+
+export type SidebarTarget =
+ | { projectId: string; kind: "local" }
+ | { projectId: string; kind: "worktree"; worktreeId: string }
+ | { projectId: string; kind: "session"; sessionId: string }
+
+export type ProjectRouteErrorCode =
+ | "project_unknown"
+ | "project_stale"
+ | "worktree_unknown"
+ | "session_unknown"
+ | "session_ambiguous"
+
+export class ProjectRouteError extends Error {
+ constructor(
+ readonly code: ProjectRouteErrorCode,
+ message: string,
+ ) {
+ super(message)
+ }
+}
+
+interface ProjectRoute {
+ root: string
+ generation: number
+ worktrees: Map
+}
+
+interface SessionRoute {
+ projectId: string
+ directory: string
+ generation: number
+}
+
+const key = (projectId: string, id: string) => `${projectId}\0${id}`
+
+export class ProjectRouteService {
+ private readonly projects = new Map()
+ private readonly sessions = new Map()
+ private readonly raw = new Map>()
+
+ registerProject(projectId: string, root: string, generation: number): void {
+ const current = this.projects.get(projectId)
+ if (current && (current.generation !== generation || current.root !== root)) this.unregisterProject(projectId)
+ if (current && current.generation === generation && current.root === root) return
+ this.projects.set(projectId, { root, generation, worktrees: new Map() })
+ }
+
+ unregisterProject(projectId: string): void {
+ this.projects.delete(projectId)
+ for (const [id, route] of [...this.sessions]) {
+ if (route.projectId !== projectId) continue
+ this.sessions.delete(id)
+ const sessionId = id.slice(id.indexOf("\0") + 1)
+ const projects = this.raw.get(sessionId)
+ projects?.delete(projectId)
+ if (projects?.size === 0) this.raw.delete(sessionId)
+ }
+ }
+
+ registerWorktree(ref: WorktreeRef, directory: string, generation: number): void {
+ const project = this.requireProject(ref.projectId, generation)
+ project.worktrees.set(ref.worktreeId, directory)
+ }
+
+ registerSession(ref: SessionRef, directory: string, generation: number): void {
+ this.requireProject(ref.projectId, generation)
+ this.sessions.set(key(ref.projectId, ref.sessionId), { projectId: ref.projectId, directory, generation })
+ const projects = this.raw.get(ref.sessionId) ?? new Set()
+ projects.add(ref.projectId)
+ this.raw.set(ref.sessionId, projects)
+ }
+
+ /**
+ * Drop a single session route. Safe to call for unknown refs; never throws.
+ * Keeps the raw index consistent so an unregistered id cannot remain
+ * ambiguous or stale.
+ */
+ unregisterSession(ref: SessionRef): void {
+ if (!this.sessions.delete(key(ref.projectId, ref.sessionId))) return
+ const projects = this.raw.get(ref.sessionId)
+ projects?.delete(ref.projectId)
+ if (projects?.size === 0) this.raw.delete(ref.sessionId)
+ }
+
+ /** Whether a session route is currently registered for this ref. */
+ hasSession(ref: SessionRef): boolean {
+ return this.sessions.has(key(ref.projectId, ref.sessionId))
+ }
+
+ /**
+ * Best-effort directory for a raw session id, without throwing.
+ *
+ * Returns the exact directory when the id maps to exactly one project,
+ * `undefined` when unknown, and `undefined` when the id is ambiguous across
+ * projects. Callers must NOT fall back to an arbitrary root for an
+ * ambiguous id — that would silently retarget the operation to the wrong
+ * project. Use {@link resolveRawSession} when a precise ref is required.
+ */
+ trySessionDirectory(sessionId: string): string | undefined {
+ const projects = this.raw.get(sessionId)
+ if (!projects?.size) return undefined
+ if (projects.size > 1) return undefined
+ const projectId = [...projects][0]!
+ const route = this.sessions.get(key(projectId, sessionId))
+ if (!route) return undefined
+ if (!this.projects.has(projectId)) return undefined
+ return route.directory
+ }
+
+ /** Whether a raw session id is known but maps to more than one project. */
+ isSessionAmbiguous(sessionId: string): boolean {
+ const projects = this.raw.get(sessionId)
+ return !!projects && projects.size > 1
+ }
+
+ inheritSession(ref: SessionRef, parent: SessionRef): void {
+ const route = this.requireSession(parent)
+ if (ref.projectId !== parent.projectId) {
+ throw new ProjectRouteError("session_unknown", "A child session cannot move between projects.")
+ }
+ this.registerSession(ref, route.directory, route.generation)
+ }
+
+ projectRoot(ref: ProjectRef, generation?: number): string {
+ return this.requireProject(ref.projectId, generation).root
+ }
+
+ worktreeDirectory(ref: WorktreeRef, generation?: number): string {
+ const project = this.requireProject(ref.projectId, generation)
+ const directory = project.worktrees.get(ref.worktreeId)
+ if (!directory) throw new ProjectRouteError("worktree_unknown", `Unknown worktree ${ref.worktreeId}.`)
+ return directory
+ }
+
+ sessionDirectory(ref: SessionRef): string {
+ return this.requireSession(ref).directory
+ }
+
+ /**
+ * Non-throwing variant of {@link sessionDirectory}. Returns the exact
+ * directory for a project-qualified session ref, or `undefined` when the
+ * project or session is unknown. Used by the KiloProvider adapter to route
+ * Agent Manager operations to an exact directory without risking an
+ * exception in message-handling paths.
+ */
+ trySessionDirectoryFor(ref: SessionRef): string | undefined {
+ const project = this.projects.get(ref.projectId)
+ if (!project) return undefined
+ const route = this.sessions.get(key(ref.projectId, ref.sessionId))
+ if (!route) return undefined
+ if (route.generation !== project.generation) return undefined
+ return route.directory
+ }
+
+ resolveRawSession(sessionId: string): SessionRef {
+ const projects = this.raw.get(sessionId)
+ if (!projects?.size) throw new ProjectRouteError("session_unknown", `Unknown session ${sessionId}.`)
+ if (projects.size > 1)
+ throw new ProjectRouteError("session_ambiguous", `Session ${sessionId} exists in multiple projects.`)
+ return { projectId: [...projects][0]!, sessionId }
+ }
+
+ static key(ref: ProjectRef & { sessionId?: string; worktreeId?: string }): string {
+ const id = ref.sessionId ?? ref.worktreeId ?? "local"
+ return key(ref.projectId, id)
+ }
+
+ private requireProject(projectId: string, generation?: number): ProjectRoute {
+ const project = this.projects.get(projectId)
+ if (!project) throw new ProjectRouteError("project_unknown", `Unknown project ${projectId}.`)
+ if (generation !== undefined && generation !== project.generation) {
+ throw new ProjectRouteError("project_stale", `Project ${projectId} has been replaced.`)
+ }
+ return project
+ }
+
+ private requireSession(ref: SessionRef): SessionRoute {
+ const route = this.sessions.get(key(ref.projectId, ref.sessionId))
+ if (!route) throw new ProjectRouteError("session_unknown", `Unknown session ${ref.sessionId} in ${ref.projectId}.`)
+ this.requireProject(ref.projectId, route.generation)
+ return route
+ }
+}
diff --git a/packages/kilo-vscode/src/agent-manager/project-scope.ts b/packages/kilo-vscode/src/agent-manager/project-scope.ts
new file mode 100644
index 0000000000..58c3716686
--- /dev/null
+++ b/packages/kilo-vscode/src/agent-manager/project-scope.ts
@@ -0,0 +1,15 @@
+import { AsyncLocalStorage } from "async_hooks"
+import type { ProjectContext } from "./project-context"
+
+/** Pins dynamic provider getters to the project that started an async operation. */
+export class ProjectScope {
+ private readonly storage = new AsyncLocalStorage()
+
+ current(): ProjectContext | undefined {
+ return this.storage.getStore()
+ }
+
+ run(ctx: ProjectContext, operation: () => Promise): Promise {
+ return this.storage.run(ctx, operation)
+ }
+}
diff --git a/packages/kilo-vscode/src/agent-manager/project-session-view.ts b/packages/kilo-vscode/src/agent-manager/project-session-view.ts
new file mode 100644
index 0000000000..ae2521e99e
--- /dev/null
+++ b/packages/kilo-vscode/src/agent-manager/project-session-view.ts
@@ -0,0 +1,4 @@
+import type { sessionToWebview } from "../kilo-provider-utils"
+
+/** A root session as shown in a project's sidebar, tagged with its worktree. */
+export type ProjectSessionView = ReturnType & { worktreeId: string | null }
diff --git a/packages/kilo-vscode/src/agent-manager/project-wiring.ts b/packages/kilo-vscode/src/agent-manager/project-wiring.ts
new file mode 100644
index 0000000000..bffff16888
--- /dev/null
+++ b/packages/kilo-vscode/src/agent-manager/project-wiring.ts
@@ -0,0 +1,93 @@
+/**
+ * Factory for the Agent Manager multi-project wiring: registry, context
+ * coordinator, message-handler dependencies, and host change listeners.
+ *
+ * Extracted from AgentManagerProvider (file-size cap). VS Code-free; the Host
+ * interface abstracts all platform capabilities.
+ */
+
+import type { Host, Disposable } from "./host"
+import type { GitOps } from "./GitOps"
+import { ProjectRegistry } from "./project-registry"
+import { ProjectContexts, type ProjectContext, type ProjectInitResult, type ProjectSnapshot } from "./project-context"
+import type { ProjectMessageDeps } from "./project-messages"
+
+export interface ProjectWiring {
+ registry: ProjectRegistry
+ contexts: ProjectContexts
+ messages: ProjectMessageDeps
+ /** Payload for the agentManager.projects webview message. */
+ snapshots(): { type: "agentManager.projects"; multiProject: boolean; projects: ProjectSnapshot[] }
+ dispose(): void
+}
+
+export function createProjectWiring(opts: {
+ host: Host
+ git: GitOps
+ log: (...args: unknown[]) => void
+ output: (msg: string) => void
+ /** Re-initialize provider state for a freshly activated context. */
+ activate: (ctx: ProjectContext) => void
+ /** Initialize an expanded background context and push its state. */
+ expand: (ctx: ProjectContext) => void
+ /** Ensure a context's repository state is ready (no-op once initialized). */
+ ready: (ctx: ProjectContext) => Promise
+ /** Push the project catalog to the webview. */
+ push: () => void
+ /** Re-derive the pinned project after workspace folder changes. */
+ changed: () => void
+ /** Re-push worktree state (e.g. after the flag toggles). */
+ refresh: () => void
+ /** Acknowledge an atomically validated sidebar selection. */
+ selected: (target: import("./project-route").SidebarTarget) => void
+}): ProjectWiring {
+ const registry = new ProjectRegistry(
+ { read: () => opts.host.readProjects(), write: (value) => opts.host.writeProjects(value) },
+ (msg) => opts.log(msg),
+ )
+ const contexts = new ProjectContexts({
+ workspaceRoot: () => opts.host.workspacePath(),
+ registry,
+ trusted: (id) => registry.get(id)?.trusted === true,
+ enabled: () => opts.host.multiProject(),
+ remove: (id) => opts.host.unregisterProjectRoutes(id),
+ deps: { log: opts.output, git: opts.git },
+ })
+ const messages: ProjectMessageDeps = {
+ registry,
+ contexts,
+ enabled: () => opts.host.multiProject(),
+ pickFolder: () => opts.host.pickFolder(),
+ activate: opts.activate,
+ expand: opts.expand,
+ ready: opts.ready,
+ push: opts.push,
+ selected: opts.selected,
+ error: (message) => opts.host.showError(message),
+ log: opts.log,
+ }
+ const listeners: Disposable[] = [
+ opts.host.onDidChangeWorkspaceFolders(() => opts.changed()),
+ opts.host.onDidChangeMultiProject((enabled) => {
+ if (!enabled) {
+ const pinned = contexts.disable()
+ if (pinned) opts.activate(pinned)
+ }
+ opts.push()
+ opts.refresh()
+ }),
+ ]
+ return {
+ registry,
+ contexts,
+ messages,
+ snapshots: () => ({
+ type: "agentManager.projects",
+ multiProject: opts.host.multiProject(),
+ projects: contexts.snapshots(),
+ }),
+ dispose: () => {
+ for (const listener of listeners) listener.dispose()
+ },
+ }
+}
diff --git a/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts
new file mode 100644
index 0000000000..6b813a5669
--- /dev/null
+++ b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts
@@ -0,0 +1,264 @@
+import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
+import { getErrorMessage } from "../kilo-provider-utils"
+import type { WorktreeManager, CreateWorktreeResult } from "./WorktreeManager"
+import { PLATFORM } from "./constants"
+import type { ManagedSession, WorktreeStateManager } from "./WorktreeStateManager"
+import type { CreateWorktreeOnDiskOptions, CreateWorktreeOnDiskResult } from "./worktree-create"
+import { recordPromotionHandoff } from "./promotion-handoff"
+import { stopSessionProcesses } from "../kilo-provider/background-process"
+
+/** Everything the worktree lifecycle handlers read from the provider. */
+export interface LifecycleDeps {
+ waitReady: (context: string) => Promise
+ createOnDisk: (opts?: CreateWorktreeOnDiskOptions) => Promise
+ setup: (dir: string, branch: string, id: string) => Promise
+ createSession: (dir: string, branch: string, id: string) => Promise
+ state: () => WorktreeStateManager | undefined
+ manager: () => WorktreeManager | undefined
+ push: () => void
+ register: (sessionId: string, dir: string) => void
+ ready: (sessionId: string, result: CreateWorktreeResult, worktreeId?: string) => void
+ registerSession: (session: Session) => void
+ clearDirectory: (sessionId: string) => void
+ directories: () => ReadonlyMap | undefined
+ abort: (sessionIds: string[]) => Promise
+ forgetPanel: (sessionId: string) => void
+ skipStats: (worktreeId: string) => void
+ removePR: (worktreeId: string) => void
+ removeRun: (worktreeId: string) => void
+ forgetName: (worktreeId: string) => void
+ stale: () => Set
+ clearStale: (worktreeId: string) => void
+ stopDiffsFor: (path: string, orphaned: ManagedSession[]) => void
+ capture: (event: string, props: Record) => void
+ autoName: () => { enabled: boolean }
+ client: () => KiloClient
+ metadata: (client: KiloClient, dir: string) => Promise>
+ root: () => string | undefined
+ post: (message: Record) => void
+ log: (...args: unknown[]) => void
+}
+
+/** Create a new worktree with an auto-created first session. */
+export async function createLifecycleWorktree(
+ deps: LifecycleDeps,
+ opts: { baseBranch?: string; branchName?: string },
+): Promise {
+ await deps.waitReady("onCreateWorktree")
+
+ const created = await deps.createOnDisk({ baseBranch: opts.baseBranch, branchName: opts.branchName })
+ if (!created) return null
+
+ // Run setup script for new worktree (blocks until complete, shows in overlay)
+ await deps.setup(created.result.path, created.result.branch, created.worktree.id)
+
+ const session = await deps.createSession(created.result.path, created.result.branch, created.worktree.id)
+ if (!session) {
+ const state = deps.state()
+ const manager = deps.manager()
+ state?.removeWorktree(created.worktree.id)
+ await manager?.removeWorktree(created.result.path)
+ deps.push()
+ return null
+ }
+
+ const state = deps.state()!
+ state.addSession(session.id, created.worktree.id)
+ if (!opts.branchName && deps.autoName().enabled) state.armAutoName(created.worktree.id, session.id)
+ deps.register(session.id, created.result.path)
+ // Push state before registerSession so the webview's sessionCreated handler
+ // sees the worktree mapping and routes the session to the worktree tab.
+ deps.ready(session.id, created.result, created.worktree.id)
+ deps.registerSession(session)
+ deps.capture("Agent Manager Session Started", {
+ source: PLATFORM,
+ sessionId: session.id,
+ worktreeId: created.worktree.id,
+ branch: created.result.branch,
+ })
+ deps.log(`Created worktree ${created.worktree.id} with session ${session.id}`)
+ return null
+}
+
+/** Delete a worktree and dissociate its sessions. */
+export async function deleteLifecycleWorktree(deps: LifecycleDeps, worktreeId: string): Promise {
+ const manager = deps.manager()
+ const state = deps.state()
+ if (!manager || !state) return null
+ const worktree = state.getWorktree(worktreeId)
+ if (!worktree) {
+ deps.log(`Worktree ${worktreeId} not found in state`)
+ return null
+ }
+ // Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree.
+ // Pre-emptive skip covers any in-flight poll that already captured getWorktrees().
+ deps.skipStats(worktreeId)
+ deps.removePR(worktreeId)
+ deps.removeRun(worktreeId)
+ deps.forgetName(worktreeId)
+ const orphaned = state.removeWorktree(worktreeId)
+ deps.stopDiffsFor(worktree.path, orphaned)
+ for (const s of orphaned) deps.clearDirectory(s.id)
+ deps.push()
+ // Disk removal after state is clean — pollers no longer reference this worktree.
+ const branch = worktree.branchOwned === false ? undefined : (worktree.originalBranch ?? worktree.branch)
+ try {
+ await manager.removeWorktree(worktree.path, branch)
+ } catch (error) {
+ deps.log(`Failed to remove worktree from disk: ${error}`)
+ }
+ deps.log(`Deleted worktree ${worktreeId}${branch ? ` (${branch})` : ""}`)
+ return null
+}
+
+/** Remove a stale worktree entry from state without touching the filesystem. */
+export async function removeStaleLifecycleWorktree(deps: LifecycleDeps, worktreeId: string): Promise {
+ const state = deps.state()
+ if (!state) return null
+ if (!deps.stale().has(worktreeId)) {
+ deps.log(`Ignored stale removal for non-stale worktree ${worktreeId}`)
+ return null
+ }
+
+ const worktree = state.getWorktree(worktreeId)
+ if (!worktree) {
+ deps.clearStale(worktreeId)
+ deps.push()
+ return null
+ }
+
+ deps.forgetName(worktreeId)
+ const orphaned = state.removeWorktree(worktreeId)
+ deps.stopDiffsFor(worktree.path, orphaned)
+ for (const session of orphaned) deps.clearDirectory(session.id)
+ deps.clearStale(worktreeId)
+ deps.push()
+ deps.log(`Removed stale worktree entry ${worktreeId} (${worktree.branch})`)
+ return null
+}
+
+/** Promote a session: create a worktree and move the session into it. */
+export async function promoteLifecycleSession(deps: LifecycleDeps, sessionId: string): Promise {
+ await deps.waitReady("onPromoteSession")
+ const created = await deps.createOnDisk({})
+ if (!created) return null
+
+ // Run setup script for new worktree (blocks until complete, shows in overlay)
+ await deps.setup(created.result.path, created.result.branch, created.worktree.id)
+
+ const state = deps.state()!
+ if (!state.getSession(sessionId)) {
+ state.addSession(sessionId, created.worktree.id)
+ } else {
+ state.moveSession(sessionId, created.worktree.id)
+ }
+
+ deps.register(sessionId, created.result.path)
+ try {
+ await recordPromotionHandoff({
+ client: deps.client(),
+ sessionId,
+ directory: created.result.path,
+ branch: created.result.branch,
+ })
+ } catch (err) {
+ deps.log("Failed to record worktree promotion handoff:", getErrorMessage(err))
+ }
+ deps.ready(sessionId, created.result, created.worktree.id)
+ deps.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`)
+ return null
+}
+
+/** Add a new or existing session to an existing worktree. */
+export async function addSessionToLifecycleWorktree(
+ deps: LifecycleDeps,
+ worktreeId: string,
+ sessionId?: string,
+): Promise {
+ let client: KiloClient
+ try {
+ client = deps.client()
+ } catch (err) {
+ deps.log("onAddSessionToWorktree: client not available:", err)
+ deps.post({ type: "error", message: "Not connected to CLI backend" })
+ return null
+ }
+
+ const state = deps.state()
+ if (!state) return null
+
+ const worktree = state.getWorktree(worktreeId)
+ if (!worktree) {
+ deps.log(`Worktree ${worktreeId} not found`)
+ return null
+ }
+
+ if (sessionId) {
+ if (state.getSession(sessionId)) state.moveSession(sessionId, worktreeId)
+ else state.addSession(sessionId, worktreeId)
+ deps.register(sessionId, worktree.path)
+ deps.push()
+ deps.post({ type: "agentManager.sessionAdded", sessionId, worktreeId })
+ deps.capture("Agent Manager Session Started", {
+ source: PLATFORM,
+ sessionId,
+ worktreeId,
+ existing: true,
+ })
+ deps.log(`Added existing session ${sessionId} to worktree ${worktreeId}`)
+ return null
+ }
+
+ let session: Session
+ try {
+ const metadata = await deps.metadata(client, worktree.path)
+ const { data } = await client.session.create(
+ { directory: worktree.path, platform: PLATFORM, metadata },
+ { throwOnError: true },
+ )
+ session = data
+ } catch (error) {
+ const err = getErrorMessage(error)
+ deps.post({ type: "error", message: `Failed to create session: ${err}` })
+ deps.capture("Agent Manager Session Error", {
+ source: PLATFORM,
+ error: err,
+ context: "addSessionToWorktree",
+ worktreeId,
+ })
+ return null
+ }
+
+ state.addSession(session.id, worktreeId)
+ deps.register(session.id, worktree.path)
+ deps.push()
+ deps.post({ type: "agentManager.sessionAdded", sessionId: session.id, worktreeId })
+ deps.registerSession(session)
+
+ deps.capture("Agent Manager Session Started", {
+ source: PLATFORM,
+ sessionId: session.id,
+ worktreeId,
+ })
+ deps.log(`Added session ${session.id} to worktree ${worktreeId}`)
+ return null
+}
+
+/** Stop a session and remove it from Agent Manager. */
+export async function closeLifecycleSession(deps: LifecycleDeps, sessionId: string): Promise {
+ const state = deps.state()
+ const dir = state?.directoryFor(sessionId) ?? deps.directories()?.get(sessionId) ?? deps.root() ?? process.cwd()
+ await deps.abort([sessionId])
+ deps.forgetPanel(sessionId)
+ try {
+ await stopSessionProcesses(deps.client(), sessionId, dir)
+ } catch (err) {
+ deps.log("onCloseSession: client not available:", err)
+ }
+
+ state?.removeSession(sessionId)
+ deps.clearDirectory(sessionId)
+ if (state) deps.push()
+ deps.log(`Closed session ${sessionId}`)
+ return null
+}
diff --git a/packages/kilo-vscode/src/agent-manager/section-handler.ts b/packages/kilo-vscode/src/agent-manager/section-handler.ts
index 624a23eb10..6a9d784a32 100644
--- a/packages/kilo-vscode/src/agent-manager/section-handler.ts
+++ b/packages/kilo-vscode/src/agent-manager/section-handler.ts
@@ -6,8 +6,13 @@ export function handleSection(
state: WorktreeStateManager | undefined,
m: AgentManagerInMessage,
push: () => void,
+ log?: (...args: unknown[]) => void,
): boolean {
- if (!state) return false
+ if (!state) {
+ if (m.type.startsWith("agentManager.") && m.type.includes("ection"))
+ log?.(`handleSection: ${m.type} dropped, state missing`)
+ return false
+ }
if (m.type === "agentManager.createSection") state.addSection(m.name, m.color ?? null, m.worktreeIds)
else if (m.type === "agentManager.renameSection") state.renameSection(m.sectionId, m.name)
else if (m.type === "agentManager.deleteSection") state.deleteSection(m.sectionId)
diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts
index 63ade03fc9..db8af59b35 100644
--- a/packages/kilo-vscode/src/agent-manager/types.ts
+++ b/packages/kilo-vscode/src/agent-manager/types.ts
@@ -16,8 +16,11 @@ import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import"
import type { ExternalWorktreeItem } from "./WorktreeManager"
import type { RunStatus } from "./run/manager"
import type { TerminalFont } from "./terminal-font"
+import type { ProjectSnapshot } from "./project-context"
+import type { SidebarTarget } from "./project-route"
export type { TerminalFont }
+export type { ProjectSnapshot }
// ---------------------------------------------------------------------------
// Shared payload types
@@ -96,11 +99,15 @@ export interface PRStatus {
interface WorktreeStatsMessage {
type: "agentManager.worktreeStats"
+ /** Owning project; absent in single-project mode. */
+ projectId?: string
stats: WorktreeStats[]
}
interface LocalStatsMessage {
type: "agentManager.localStats"
+ /** Owning project; absent in single-project mode. */
+ projectId?: string
stats: LocalStats
}
@@ -140,6 +147,38 @@ interface StateMessage {
runStatuses?: RunStatus[]
runScriptConfigured?: boolean
runScriptPath?: string
+ /** Owning project for this state payload. Absent in legacy single-project payloads. */
+ projectId?: string
+ /** Last selected sidebar target for seamless project-switch restore. */
+ activeTarget?: SidebarTarget
+}
+
+/** Project catalog pushed to the webview after registry or context changes. */
+interface ProjectsMessage {
+ type: "agentManager.projects"
+ /** Whether the multi-project experiment is enabled. */
+ multiProject: boolean
+ projects: ProjectSnapshot[]
+}
+
+interface SelectionActivatedMessage {
+ type: "agentManager.selectionActivated"
+ target: SidebarTarget
+}
+
+interface ProjectSessionsMessage {
+ type: "agentManager.projectSessions"
+ projectId: string
+ sessions: Array<{
+ id: string
+ parentID?: string | null
+ title?: string
+ createdAt: string
+ updatedAt: string
+ worktreeId: string | null
+ revert?: unknown
+ summary?: unknown
+ }>
}
// ---------------------------------------------------------------------------
@@ -224,6 +263,7 @@ interface SendInitialMessage {
interface BranchesMessage {
type: "agentManager.branches"
+ projectId?: string
branches: (BranchListItem & { isCheckedOut?: boolean })[]
defaultBranch: string
}
@@ -288,6 +328,8 @@ interface RevertWorktreeFileResultMessage {
interface PRStatusOutMessage {
type: "agentManager.prStatus"
+ /** Owning project; absent in single-project mode. */
+ projectId?: string
worktreeId: string
pr: PRStatus | null
error?: "gh_missing" | "gh_auth" | "fetch_failed"
@@ -309,6 +351,9 @@ export type AgentManagerOutMessage =
| WorktreeSetupMessage
| SessionMetaMessage
| StateMessage
+ | ProjectsMessage
+ | SelectionActivatedMessage
+ | ProjectSessionsMessage
| ErrorOutMessage
| SessionAddedMessage
| SessionForkedMessage
@@ -342,25 +387,80 @@ interface CreateWorktreeIn {
type: "agentManager.createWorktree"
baseBranch?: string
branchName?: string
+ /** Target project. Must match the active project when present; mismatches are dropped. */
+ projectId?: string
+}
+
+/** Request the current project catalog. */
+interface RequestProjectsIn {
+ type: "agentManager.requestProjects"
+}
+
+/** Add a repository as a project via the host folder picker. */
+interface AddProjectIn {
+ type: "agentManager.addProject"
+}
+
+/** Remove a project from the catalog. Never deletes repository data. */
+interface RemoveProjectIn {
+ type: "agentManager.removeProject"
+ projectId: string
+}
+
+/** Make a project the active context. */
+interface SelectProjectIn {
+ type: "agentManager.selectProject"
+ projectId: string
+}
+
+interface ActivateSelectionIn {
+ type: "agentManager.activateSelection"
+ target: SidebarTarget
+ /** Resolve the project's persisted target instead of using `target` verbatim. */
+ restore?: boolean
+}
+
+/** Persist the webview's current selection for seamless restore after switching back. */
+interface RememberTargetIn {
+ type: "agentManager.rememberTarget"
+ projectId: string
+ target: SidebarTarget
+}
+
+/** Expand or collapse a project accordion without changing the active project. */
+interface SetProjectExpandedIn {
+ type: "agentManager.setProjectExpanded"
+ projectId: string
+ expanded: boolean
+}
+
+/** Grant a project permission to run project-controlled scripts and load state. */
+interface TrustProjectIn {
+ type: "agentManager.trustProject"
+ projectId: string
}
interface DeleteWorktreeIn {
type: "agentManager.deleteWorktree"
+ projectId?: string
worktreeId: string
}
interface RemoveStaleWorktreeIn {
type: "agentManager.removeStaleWorktree"
+ projectId?: string
worktreeId: string
}
interface PromoteSessionIn {
type: "agentManager.promoteSession"
+ projectId?: string
sessionId: string
}
interface OpenLocallyIn {
type: "agentManager.openLocally"
+ projectId?: string
sessionId: string
}
@@ -390,14 +490,17 @@ interface ForgetSessionIn {
interface ConfigureSetupScriptIn {
type: "agentManager.configureSetupScript"
+ projectId?: string
}
interface ConfigureRunScriptIn {
type: "agentManager.configureRunScript"
+ projectId?: string
}
interface RunScriptIn {
type: "agentManager.runScript"
+ projectId?: string
worktreeId: string
}
@@ -417,6 +520,7 @@ interface ShowLocalTerminalIn {
interface OpenWorktreeIn {
type: "agentManager.openWorktree"
+ projectId?: string
worktreeId: string
}
@@ -435,6 +539,7 @@ interface RequestRepoInfoIn {
interface CreateMultiVersionIn {
type: "agentManager.createMultiVersion"
+ projectId?: string
text?: string
name?: string
versions?: number
@@ -452,6 +557,7 @@ interface CreateMultiVersionIn {
interface RenameWorktreeIn {
type: "agentManager.renameWorktree"
+ projectId?: string
worktreeId: string
label: string
}
@@ -462,6 +568,7 @@ interface RequestStateIn {
interface RequestBranchesIn {
type: "agentManager.requestBranches"
+ projectId?: string
}
interface SetTabOrderIn {
@@ -477,6 +584,7 @@ interface SetWorktreeOrderIn {
interface SetSessionsCollapsedIn {
type: "agentManager.setSessionsCollapsed"
+ projectId?: string
collapsed: boolean
}
@@ -497,6 +605,7 @@ interface SetReviewMarkdownRenderIn {
interface SetDefaultBaseBranchIn {
type: "agentManager.setDefaultBaseBranch"
+ projectId?: string
branch?: string
}
@@ -506,11 +615,13 @@ interface RequestExternalWorktreesIn {
interface ImportFromBranchIn {
type: "agentManager.importFromBranch"
+ projectId?: string
branch: string
}
interface ImportFromPRIn {
type: "agentManager.importFromPR"
+ projectId?: string
url: string
}
@@ -563,6 +674,7 @@ interface RefreshPRIn {
interface OpenPRIn {
type: "agentManager.openPR"
+ projectId?: string
worktreeId: string
}
@@ -713,6 +825,7 @@ interface ContinueInWorktreeIn {
interface CreateSectionIn {
type: "agentManager.createSection"
+ projectId?: string
name: string
color?: string
worktreeIds?: string[]
@@ -720,34 +833,40 @@ interface CreateSectionIn {
interface RenameSectionIn {
type: "agentManager.renameSection"
+ projectId?: string
sectionId: string
name: string
}
interface DeleteSectionIn {
type: "agentManager.deleteSection"
+ projectId?: string
sectionId: string
}
interface SetSectionColorIn {
type: "agentManager.setSectionColor"
+ projectId?: string
sectionId: string
color: string | null
}
interface ToggleSectionCollapsedIn {
type: "agentManager.toggleSectionCollapsed"
+ projectId?: string
sectionId: string
}
interface MoveToSectionIn {
type: "agentManager.moveToSection"
+ projectId?: string
worktreeIds: string[]
sectionId: string | null
}
interface MoveSectionIn {
type: "agentManager.moveSection"
+ projectId?: string
sectionId: string
dir: -1 | 1
}
@@ -777,6 +896,14 @@ interface TerminalResizeIn {
/** All messages the Agent Manager expects from the webview (onMessage input). */
export type AgentManagerInMessage =
| CreateWorktreeIn
+ | RequestProjectsIn
+ | AddProjectIn
+ | RemoveProjectIn
+ | SelectProjectIn
+ | ActivateSelectionIn
+ | RememberTargetIn
+ | SetProjectExpandedIn
+ | TrustProjectIn
| DeleteWorktreeIn
| RemoveStaleWorktreeIn
| PromoteSessionIn
diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts
index 69dd31a858..45ac42d328 100644
--- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts
+++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts
@@ -6,7 +6,9 @@
*/
import * as vscode from "vscode"
+import type { Session } from "@kilocode/sdk/v2/client"
import type { Host, PanelContext, OutputHandle, SessionProvider, Disposable } from "./host"
+import { ProjectRouteService } from "./project-route"
import type { KiloConnectionService } from "../services/cli-backend"
import { KiloProvider } from "../KiloProvider"
import { PLATFORM, SNAPSHOT_INITIALIZATION } from "./constants"
@@ -20,6 +22,12 @@ import type { RemoteStatusService } from "../services/RemoteStatusService"
export class VscodeHost implements Host {
private diffVirtual: DiffVirtualProvider | undefined
private autoApprove: AutoApproveController | undefined
+ /**
+ * Shared project route registry for every Agent Manager panel opened by
+ * this host. One service keeps raw session id ambiguity consistent across
+ * panels, so two panels never disagree about whether an id is ambiguous.
+ */
+ private readonly routes = new ProjectRouteService()
constructor(
private readonly extensionUri: vscode.Uri,
@@ -59,6 +67,8 @@ export class VscodeHost implements Host {
opts: {
onBeforeMessage: (msg: Record) => Promise | null>
worktreeDirectories?: () => string[]
+ workspaceRoot?: () => string | undefined
+ projectId?: () => string | undefined
},
): PanelContext {
return this.wirePanel(panel, opts)
@@ -69,6 +79,8 @@ export class VscodeHost implements Host {
opts: {
onBeforeMessage: (msg: Record) => Promise | null>
worktreeDirectories?: () => string[]
+ workspaceRoot?: () => string | undefined
+ projectId?: () => string | undefined
},
): PanelContext {
panel.webview.options = {
@@ -96,7 +108,13 @@ export class VscodeHost implements Host {
snapshotInitialization: SNAPSHOT_INITIALIZATION,
slimEditMetadata: true,
worktreeDirectories: () => opts.worktreeDirectories?.() ?? [],
+ rootDirectory: opts.workspaceRoot,
disableViewedRegistration: true,
+ routeService: this.routes,
+ projectQualifier: () => {
+ const projectId = opts.projectId?.()
+ return projectId ? { projectId } : undefined
+ },
})
if (this.diffVirtual) {
provider.setDiffVirtualProvider(this.diffVirtual)
@@ -116,6 +134,7 @@ export class VscodeHost implements Host {
clearSessionDirectory: (id) => provider.clearSessionDirectory(id),
getSessionDirectories: () => provider.getSessionDirectories(),
getSessionInfo: (id) => provider.getSessionInfo(id),
+ listSessions: (dir) => this.listProjectSessions(dir),
trackSession: (id) => provider.trackSession(id),
refreshSessions: () => provider.refreshSessions(),
registerSession: (s) => provider.registerSession(s),
@@ -125,6 +144,13 @@ export class VscodeHost implements Host {
abortSessions: (ids) => provider.abortSessions(ids),
showMemory: (id) => provider.showMemory(id),
toggleMemory: (id) => provider.toggleMemory(id),
+ registerProjectRoute: (ref, root, generation) => provider.registerProjectRoute(ref, root, generation),
+ unregisterProjectRoute: (projectId) => provider.unregisterProjectRoute(projectId),
+ registerWorktreeRoute: (ref, directory, generation) => provider.registerWorktreeRoute(ref, directory, generation),
+ registerSessionRoute: (ref, directory, generation) => provider.registerSessionRoute(ref, directory, generation),
+ unregisterSessionRoute: (ref) => provider.unregisterSessionRoute(ref),
+ isSessionRouteAmbiguous: (sessionId) => provider.isSessionRouteAmbiguous(sessionId),
+ routeSessionDirectoryFor: (ref) => provider.routeSessionDirectoryFor(ref),
dispose: () => provider.dispose(),
}
@@ -169,10 +195,66 @@ export class VscodeHost implements Host {
}
}
+ /**
+ * List root sessions for one project directory via the shared CLI backend.
+ * Used by per-project session discovery so multi-project Agent Manager lists
+ * real Local/history sessions by their exact directory instead of only the
+ * persisted managed records. Returns [] when the backend is not connected or
+ * the listing fails, so one directory's failure cannot erase another's
+ * results.
+ */
+ private async listProjectSessions(dir: string): Promise {
+ try {
+ const client = await this.connectionService.getClientAsync(dir)
+ const res = await client.session.list({ directory: dir, roots: true }, { throwOnError: true })
+ return res.data
+ } catch (err) {
+ console.warn(`[Kilo New] Agent Manager: failed to list project sessions for ${dir}:`, err)
+ return []
+ }
+ }
+
workspacePath(): string | undefined {
return getWorkspaceRoot()
}
+ async pickFolder(): Promise {
+ const uris = await vscode.window.showOpenDialog({
+ canSelectFiles: false,
+ canSelectFolders: true,
+ canSelectMany: false,
+ openLabel: "Add Project",
+ title: "Add Project to Agent Manager",
+ })
+ return uris?.[0]?.fsPath
+ }
+
+ multiProject(): boolean {
+ return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("multiProject", false)
+ }
+
+ readProjects(): unknown {
+ return this.context.globalState.get("agentManager.projects")
+ }
+
+ async writeProjects(value: unknown): Promise {
+ await this.context.globalState.update("agentManager.projects", value)
+ }
+
+ unregisterProjectRoutes(projectId: string): void {
+ this.routes.unregisterProject(projectId)
+ }
+
+ onDidChangeWorkspaceFolders(cb: () => void): Disposable {
+ return vscode.workspace.onDidChangeWorkspaceFolders(() => cb())
+ }
+
+ onDidChangeMultiProject(cb: (enabled: boolean) => void): Disposable {
+ return vscode.workspace.onDidChangeConfiguration((e) => {
+ if (e.affectsConfiguration("kilo-code.new.experimental.multiProject")) cb(this.multiProject())
+ })
+ }
+
autoBranchNaming(): { enabled: boolean; prefix: string } {
const cfg = vscode.workspace.getConfiguration("kilo-code.new.agentManager")
return {
diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts
index c750637d18..b1549e3c47 100644
--- a/packages/kilo-vscode/src/extension.ts
+++ b/packages/kilo-vscode/src/extension.ts
@@ -203,6 +203,8 @@ export function activate(context: vscode.ExtensionContext) {
const ctx = agentManagerHost.wrapExistingPanel(panel, {
onBeforeMessage: (msg) => agentManagerProvider.handleMessage(msg),
worktreeDirectories: () => agentManagerProvider.getWorktreeDirectories(),
+ workspaceRoot: () => agentManagerProvider.workspaceRoot(),
+ projectId: () => agentManagerProvider.projectId(),
})
agentManagerProvider.deserializePanel(ctx)
return Promise.resolve()
diff --git a/packages/kilo-vscode/src/indexing-consent.ts b/packages/kilo-vscode/src/indexing-consent.ts
new file mode 100644
index 0000000000..a768320c0f
--- /dev/null
+++ b/packages/kilo-vscode/src/indexing-consent.ts
@@ -0,0 +1,103 @@
+import * as path from "path"
+import type * as vscode from "vscode"
+import simpleGit from "simple-git"
+import { canonicalizePath, projectIdFor, resolveProjectRoot, samePath } from "./agent-manager/project-paths"
+import { ProjectRegistry } from "./agent-manager/project-registry"
+
+const KEY = "kilo.indexingConsent.v1"
+
+interface File {
+ version: 1
+ projects: Record
+}
+
+export interface IndexingProject {
+ id: string
+ root: string
+ label: string
+}
+
+interface Storage {
+ read(): unknown
+ write(value: unknown): PromiseLike | void
+}
+
+function parse(raw: unknown): File {
+ if (!raw || typeof raw !== "object") return { version: 1, projects: {} }
+ const file = raw as Partial
+ if (file.version !== 1 || !file.projects || typeof file.projects !== "object") {
+ return { version: 1, projects: {} }
+ }
+ return {
+ version: 1,
+ projects: Object.fromEntries(Object.entries(file.projects).filter((entry) => typeof entry[1] === "boolean")),
+ }
+}
+
+export class IndexingConsentStore {
+ private queue: Promise = Promise.resolve()
+
+ constructor(
+ private readonly storage: Storage,
+ private readonly resolve: (dir: string) => Promise,
+ ) {}
+
+ async project(dir: string): Promise {
+ const root = (await this.resolve(dir)) ?? canonicalizePath(dir)
+ return { id: projectIdFor(root), root, label: path.basename(root) }
+ }
+
+ enabled(id: string): boolean {
+ return parse(this.storage.read()).projects[id] === true
+ }
+
+ set(id: string, enabled: boolean): Promise {
+ const task = this.queue
+ .catch(() => {})
+ .then(async () => {
+ const file = parse(this.storage.read())
+ await this.storage.write({ ...file, projects: { ...file.projects, [id]: enabled } })
+ })
+ this.queue = task.then(
+ () => {},
+ () => {},
+ )
+ return task
+ }
+
+ async list(primary: string | undefined, extras: Array<{ root: string; label?: string }>): Promise {
+ const dirs = [...(primary ? [{ root: primary }] : []), ...extras]
+ const projects = await Promise.all(
+ dirs.map(async (entry) => ({
+ ...(await this.project(entry.root)),
+ label: entry.label ?? path.basename(entry.root),
+ })),
+ )
+ return projects.filter(
+ (project, index) => !projects.slice(0, index).some((item) => samePath(item.root, project.root)),
+ )
+ }
+}
+
+const stores = new WeakMap()
+
+export function indexingConsentStore(context: vscode.ExtensionContext): IndexingConsentStore {
+ const existing = stores.get(context)
+ if (existing) return existing
+ const storage = {
+ read: () => context.globalState.get(KEY),
+ write: (value: unknown) => context.globalState.update(KEY, value),
+ }
+ const store = new IndexingConsentStore(storage, (dir) =>
+ resolveProjectRoot(dir, (cwd, args) => simpleGit(cwd).raw(args)),
+ )
+ stores.set(context, store)
+ return store
+}
+
+export function registeredProjects(context: vscode.ExtensionContext) {
+ return new ProjectRegistry({
+ read: () => context.globalState.get("agentManager.projects"),
+ write: (value) => Promise.resolve(context.globalState.update("agentManager.projects", value)),
+ }).list()
+}
diff --git a/packages/kilo-vscode/src/kilo-provider/config-bindings.ts b/packages/kilo-vscode/src/kilo-provider/config-bindings.ts
new file mode 100644
index 0000000000..122e4f9f4b
--- /dev/null
+++ b/packages/kilo-vscode/src/kilo-provider/config-bindings.ts
@@ -0,0 +1,58 @@
+import { randomUUID } from "crypto"
+
+export type ConfigScope = "global" | "project"
+
+export interface ConfigTarget {
+ scope: ConfigScope
+ path: string
+ revision: string
+ exists: boolean
+ writable: boolean
+ raw: Record
+}
+
+export interface ConfigProject {
+ id: string
+ root: string
+ generation: number
+ pinned: boolean
+}
+
+export interface ConfigBinding {
+ id: string
+ connection: number
+ scope: ConfigScope
+ directory: string
+ target: ConfigTarget
+ project?: ConfigProject
+}
+
+export class ConfigBindings {
+ private readonly bindings = new Map()
+
+ create(input: Omit): ConfigBinding {
+ const binding = { ...input, id: randomUUID() }
+ this.bindings.set(binding.id, binding)
+ return binding
+ }
+
+ get(
+ id: string | undefined,
+ connection: number,
+ valid: (project: ConfigProject) => boolean,
+ ): ConfigBinding | undefined {
+ if (!id) return undefined
+ const binding = this.bindings.get(id)
+ if (!binding || binding.connection !== connection) return undefined
+ if (binding.project && !valid(binding.project)) return undefined
+ return binding
+ }
+
+ consume(id: string): void {
+ this.bindings.delete(id)
+ }
+
+ clear(): void {
+ this.bindings.clear()
+ }
+}
diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts
index 67cb6634b2..1cc94ba2b6 100644
--- a/packages/kilo-vscode/src/kilo-provider/early-message.ts
+++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts
@@ -4,6 +4,8 @@ import { routeInputToolMessage } from "../services/input-tools"
import type { KiloConnectionService } from "../services/cli-backend/connection-service"
import type { SuggestionContext } from "./handlers/suggestion"
import type { KiloClient } from "@kilocode/sdk/v2/client"
+import { buildChatSettingsMessage } from "./chat-settings"
+import { buildThroughputSettingMessage } from "./throughput-settings"
type Ctx = {
question: SuggestionContext
@@ -11,6 +13,7 @@ type Ctx = {
connection: KiloConnectionService
dir: string
post: (msg: unknown) => void
+ browserSettings: () => void
exportTranscript: (sessionID: string) => Promise
openSessions: (ids: string[]) => void
}
@@ -31,5 +34,17 @@ export async function routeEarlyMessage(message: { type: string }, ctx: Ctx): Pr
ctx.openSessions(ids)
return true
}
+ if (message.type === "requestChatSettings") {
+ ctx.post(buildChatSettingsMessage())
+ return true
+ }
+ if (message.type === "requestThroughputSetting") {
+ ctx.post(buildThroughputSettingMessage())
+ return true
+ }
+ if (message.type === "requestBrowserSettings") {
+ ctx.browserSettings()
+ return true
+ }
return await routeInputToolMessage(message, { connection: ctx.connection, dir: ctx.dir, post: ctx.post })
}
diff --git a/packages/kilo-vscode/src/kilo-provider/indexing-settings.ts b/packages/kilo-vscode/src/kilo-provider/indexing-settings.ts
index 8fbd0ace48..be8a580a40 100644
--- a/packages/kilo-vscode/src/kilo-provider/indexing-settings.ts
+++ b/packages/kilo-vscode/src/kilo-provider/indexing-settings.ts
@@ -1,21 +1,23 @@
import * as vscode from "vscode"
+import type { IndexingProject } from "../indexing-consent"
-type Post = (msg: unknown) => void
-
-export function buildIndexingSettingsMessage() {
+export function buildIndexingSettingsMessage(consent = false, projects: IndexingProject[] = [], projectId?: string) {
const config = vscode.workspace.getConfiguration("kilo-code.new.indexing")
return {
type: "indexingSettingsLoaded" as const,
settings: {
showButtonWhenDisabled: config.get("showButtonWhenDisabled", true),
+ consent,
+ projects,
+ projectId,
},
}
}
-export function watchIndexingConfig(post: Post): vscode.Disposable {
+export function watchIndexingConfig(post: () => void): vscode.Disposable {
return vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration("kilo-code.new.indexing")) {
- post(buildIndexingSettingsMessage())
+ post()
}
})
}
diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts
index 63b76a2766..b511189c80 100644
--- a/packages/kilo-vscode/src/kilo-provider/options.ts
+++ b/packages/kilo-vscode/src/kilo-provider/options.ts
@@ -1,3 +1,5 @@
+import type { ProjectRouteService } from "../agent-manager/project-route"
+
export type KiloProviderOptions = {
/** Context key updated from focus events reported by this provider's webview. */
focusContext?: string
@@ -7,6 +9,27 @@ export type KiloProviderOptions = {
slimEditMetadata?: boolean
tabTitle?: (title: string) => void
worktreeDirectories?: () => string[]
+ /**
+ * Dynamic root directory override. When present, it replaces the
+ * workspaceFolders[0] fallback so the provider's sessions, prompts, and
+ * refreshes follow the host's active project (Agent Manager).
+ */
+ rootDirectory?: () => string | undefined
/** Composite hosts (Agent Manager) own viewed/presence registration themselves. */
disableViewedRegistration?: boolean
+ /**
+ * Project route registry shared by all Agent Manager panels. When set, the
+ * provider resolves project-qualified session refs to exact directories and
+ * refuses to silently retarget an ambiguous raw session id to the active
+ * root. Non-Agent-Manager providers leave this undefined and behave exactly
+ * as before.
+ */
+ routeService?: ProjectRouteService
+ /**
+ * Resolve the active Agent Manager project for a raw session id, so a
+ * project-qualified {@link SessionRef} can be built when the caller does not
+ * already carry one. Returns undefined when no project is active or the id
+ * is ambiguous.
+ */
+ projectQualifier?: () => { projectId: string } | undefined
}
diff --git a/packages/kilo-vscode/tests/unit/agent-local-tabs-store.test.ts b/packages/kilo-vscode/tests/unit/agent-local-tabs-store.test.ts
new file mode 100644
index 0000000000..ce85b5a8f1
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-local-tabs-store.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "bun:test"
+import { createRoot } from "solid-js"
+import { createLocalTabs } from "../../webview-ui/agent-manager/local-tabs-store"
+
+const pending = (id: string) => id.startsWith("pending-")
+
+describe("createLocalTabs", () => {
+ it("keeps each project's open tabs to itself", () =>
+ createRoot((dispose) => {
+ let project = "prj-a"
+ const tabs = createLocalTabs(undefined, () => project)
+
+ tabs.set(["ses_a1", "ses_a2"])
+ expect(tabs.ids()).toEqual(["ses_a1", "ses_a2"])
+
+ // Switching projects must not carry the previous project's tabs over.
+ project = "prj-b"
+ expect(tabs.ids()).toEqual([])
+
+ tabs.set(["ses_b1"])
+ expect(tabs.ids()).toEqual(["ses_b1"])
+
+ project = "prj-a"
+ expect(tabs.ids()).toEqual(["ses_a1", "ses_a2"])
+ dispose()
+ }))
+
+ it("applies updater functions to the active project only", () =>
+ createRoot((dispose) => {
+ let project = "prj-a"
+ const tabs = createLocalTabs(undefined, () => project)
+ tabs.set(["ses_a1"])
+ project = "prj-b"
+ tabs.set((prev) => [...prev, "ses_b1"])
+
+ expect(tabs.ids()).toEqual(["ses_b1"])
+ project = "prj-a"
+ expect(tabs.ids()).toEqual(["ses_a1"])
+ dispose()
+ }))
+
+ it("keeps the same array reference when the value does not change", () =>
+ createRoot((dispose) => {
+ const tabs = createLocalTabs(undefined, () => "prj-a")
+ tabs.set(["ses_a1"])
+ const first = tabs.ids()
+ tabs.set((prev) => prev)
+ expect(tabs.ids()).toBe(first)
+ dispose()
+ }))
+
+ it("migrates the legacy single-project list into the single bucket", () =>
+ createRoot((dispose) => {
+ let project = "single"
+ const tabs = createLocalTabs({ localSessionIDs: ["ses_old"] }, () => project)
+ expect(tabs.ids()).toEqual(["ses_old"])
+ project = "prj-a"
+ expect(tabs.ids()).toEqual([])
+ dispose()
+ }))
+
+ it("restores persisted per-project buckets", () =>
+ createRoot((dispose) => {
+ let project = "prj-b"
+ const tabs = createLocalTabs({ localTabs: { "prj-a": ["ses_a1"], "prj-b": ["ses_b1"] } }, () => project)
+ expect(tabs.ids()).toEqual(["ses_b1"])
+ project = "prj-a"
+ expect(tabs.ids()).toEqual(["ses_a1"])
+ dispose()
+ }))
+
+ it("strips ephemeral tabs from every bucket before persisting", () =>
+ createRoot((dispose) => {
+ let project = "prj-a"
+ const tabs = createLocalTabs(undefined, () => project)
+ tabs.set(["ses_a1", "pending-1"])
+ project = "prj-b"
+ tabs.set(["pending-2", "ses_b1"])
+
+ expect(tabs.durable(pending)).toEqual({ "prj-a": ["ses_a1"], "prj-b": ["ses_b1"] })
+ dispose()
+ }))
+})
diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts
index ff6a272555..7314e1a04e 100644
--- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts
+++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts
@@ -40,6 +40,13 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/SidebarSearchMenu.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SidebarToggleButton.tsx"),
path.join(ROOT, "webview-ui/agent-manager/WorktreeSectionActions.tsx"),
+ path.join(ROOT, "webview-ui/agent-manager/ProjectsSection.tsx"),
+ path.join(ROOT, "webview-ui/agent-manager/ProjectSidebarBody.tsx"),
+ path.join(ROOT, "webview-ui/agent-manager/ProjectList.tsx"),
+ path.join(ROOT, "webview-ui/agent-manager/ProjectActions.tsx"),
+ path.join(ROOT, "webview-ui/agent-manager/SidebarBody.tsx"),
+ path.join(ROOT, "webview-ui/agent-manager/TabBar.tsx"),
+ path.join(ROOT, "webview-ui/agent-manager/ProjectBranchDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"),
path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalTab.tsx"),
path.join(ROOT, "webview-ui/agent-manager/terminal/SortableTerminalTab.tsx"),
@@ -149,7 +156,15 @@ describe("Agent Manager Provider Messages", () => {
const cls = source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration)
const method = cls?.getMethod(name)
expect(method, `method ${name} not found in AgentManagerProvider`).toBeTruthy()
- return method!.getText()
+ const text = method!.getText()
+ // Follow one-line delegations into the extracted lifecycle module so the
+ // assertions keep covering the real handler logic.
+ const delegated = text.match(/return (\w+Lifecycle\w+)\(/)
+ if (!delegated) return text
+ const lifecycle = project.addSourceFileAtPath(path.join(ROOT, "src/agent-manager/provider-lifecycle.ts"))
+ const fn = lifecycle.getFunction(delegated[1]!)
+ expect(fn, `delegated function ${delegated[1]} not found in provider-lifecycle`).toBeTruthy()
+ return fn!.getText()
}
/**
@@ -177,6 +192,36 @@ describe("Agent Manager Provider Messages", () => {
expect(warmup).toBeLessThan(create)
})
+ /**
+ * Regression: WorktreeDiffController calls getRoot() eagerly during
+ * construction, so the project contexts must exist before it is created.
+ * Constructing them later crashed extension activation at runtime.
+ */
+ it("creates project wiring before services that eagerly read the root", () => {
+ const text = fs.readFileSync(PROVIDER_FILE, "utf-8")
+ const wiring = text.indexOf("createProjectWiring(")
+ const diffs = text.indexOf("new WorktreeDiffController(")
+
+ expect(wiring).toBeGreaterThanOrEqual(0)
+ expect(diffs).toBeGreaterThanOrEqual(0)
+ expect(wiring).toBeLessThan(diffs)
+ })
+
+ /**
+ * Regression: project-management messages must be consumed before the
+ * state gate, because selectProject triggers the state initialization
+ * that later messages wait for.
+ */
+ it("handles project messages before state-gated dispatch", () => {
+ const body = getMethodBody("onMessage") + getMethodBody("dispatchMessage")
+ const projects = body.indexOf("handleProjectMessage(m, this.projects)")
+ const gate = body.indexOf("if (this.shouldWaitForState(m))")
+
+ expect(projects).toBeGreaterThanOrEqual(0)
+ expect(gate).toBeGreaterThanOrEqual(0)
+ expect(projects).toBeLessThan(gate)
+ })
+
it("state-mutating messages wait for state initialization", () => {
const body = getMethodBody("shouldWaitForState")
const messages = [
@@ -196,13 +241,13 @@ describe("Agent Manager Provider Messages", () => {
expect(body, `${message} should wait for loaded state`).toContain(message)
}
- expect(getMethodBody("onMessage")).toContain("if (this.shouldWaitForState(m)) await this.waitForStateReady(m.type)")
+ expect(getMethodBody("dispatchMessage")).toContain("if (this.shouldWaitForState(m))")
})
- it("initializeState updates local git exclude before loading persisted state", () => {
- const body = getMethodBody("initializeState")
- const exclude = body.indexOf("await this.ensureGitExclude(manager)")
- const load = body.indexOf("const loaded = await state.load()")
+ it("context state init updates local git exclude before loading persisted state", () => {
+ const text = fs.readFileSync(path.join(ROOT, "src/agent-manager/project-init.ts"), "utf-8")
+ const exclude = text.indexOf("ensureGitExclude(")
+ const load = text.indexOf("state.load()")
expect(exclude).toBeGreaterThanOrEqual(0)
expect(load).toBeGreaterThanOrEqual(0)
@@ -225,7 +270,7 @@ describe("Agent Manager Provider Messages", () => {
expect(body).toContain("closedDrafts.add(sessionId)")
expect(body).toContain('vscode.postMessage({ type: "agentManager.closeSession", sessionId })')
expect(body).not.toContain('type: "agentManager.forgetSession"')
- expect(getMethodBody("onCloseSession")).toContain("await this.panel?.sessions.abortSessions([sessionId])")
+ expect(getMethodBody("onCloseSession")).toContain("await deps.abort([sessionId])")
expect(text).toContain("if (created.draftID && closedDrafts.delete(created.draftID)) return")
})
@@ -376,7 +421,17 @@ describe("Agent Manager Provider — onMessage routing", () => {
setup()
const method = cls.getMethod(name)
expect(method, `method ${name} not found`).toBeTruthy()
- return method!.getText()
+ const text = method!.getText()
+ // Follow one-line delegations into the extracted lifecycle module so the
+ // assertions keep covering the real handler logic.
+ const delegated = text.match(/return (\w+Lifecycle\w+)\(/)
+ if (!delegated) return text
+ const lifecycle = source
+ .getProject()
+ .addSourceFileAtPath(path.join(ROOT, "src/agent-manager/provider-lifecycle.ts"))
+ const fn = lifecycle.getFunction(delegated[1]!)
+ expect(fn, `delegated function ${delegated[1]} not found in provider-lifecycle`).toBeTruthy()
+ return fn!.getText()
}
function provider(): string {
@@ -446,7 +501,7 @@ describe("Agent Manager Provider — onMessage routing", () => {
})
it("onMessage delegates to cohesive routing groups", () => {
- const text = body("onMessage")
+ const text = body("onMessage") + body("dispatchMessage")
expect(text).toContain("onWorktreeMessage")
expect(text).toContain("onSessionMessage")
expect(text).toContain("onImportMessage")
@@ -464,8 +519,8 @@ describe("Agent Manager Provider — onMessage routing", () => {
const text = body("onDeleteWorktree")
expect(text).toContain("manager.removeWorktree")
expect(text).toContain("state.removeWorktree")
- expect(text).toContain("clearSessionDirectory")
- expect(text).toContain("this.pushState()")
+ expect(text).toContain("deps.clearDirectory")
+ expect(text).toContain("deps.push()")
})
// -- onCreateWorktree invariants -------------------------------------------
@@ -477,8 +532,8 @@ describe("Agent Manager Provider — onMessage routing", () => {
*/
it("onCreateWorktree runs setup script before creating session", () => {
const text = body("onCreateWorktree")
- const setupIdx = text.indexOf("runSetupScriptForWorktree")
- const sessionIdx = text.indexOf("createSessionInWorktree")
+ const setupIdx = text.indexOf("deps.setup(")
+ const sessionIdx = text.indexOf("deps.createSession(")
expect(setupIdx, "setup script call must exist").toBeGreaterThan(-1)
expect(sessionIdx, "session creation call must exist").toBeGreaterThan(-1)
expect(setupIdx, "setup script must run before session creation").toBeLessThan(sessionIdx)
@@ -500,7 +555,7 @@ describe("Agent Manager Provider — onMessage routing", () => {
*/
it("onPromoteSession runs setup script before modifying session", () => {
const text = body("onPromoteSession")
- const setupIdx = text.indexOf("runSetupScriptForWorktree")
+ const setupIdx = text.indexOf("deps.setup(")
const moveIdx = text.indexOf("moveSession")
expect(setupIdx).toBeGreaterThan(-1)
expect(moveIdx).toBeGreaterThan(-1)
@@ -593,7 +648,7 @@ describe("Agent Manager Webview — non-git sessionsLoaded fix", () => {
// Find the agentManager.state handler block
const start = tsx.indexOf('"agentManager.state"')
expect(start, "agentManager.state handler must exist").toBeGreaterThan(-1)
- const snippet = tsx.slice(start, start + 800)
+ const snippet = tsx.slice(start, start + 1600)
expect(snippet, "must call setSessionsLoaded in the non-git branch").toContain("setSessionsLoaded")
expect(snippet, "must check isGitRepo === false before setting sessionsLoaded").toMatch(
/isGitRepo.*false|false.*isGitRepo/,
@@ -804,8 +859,8 @@ const VSCODE_ALLOWED: Record = {
*/
const MAX_LINES: Record = {
"AgentManagerProvider.ts": {
- maxLines: 2000,
- note: "diff and import workflows are extracted into cohesive domain services; extract more orchestration next",
+ maxLines: 1900,
+ note: "worktree lifecycle handlers extracted into provider-lifecycle.ts; extract more orchestration next",
},
}
diff --git a/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts b/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts
new file mode 100644
index 0000000000..93ecca7169
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-project-contexts.test.ts
@@ -0,0 +1,236 @@
+import { describe, it, expect } from "bun:test"
+import { ProjectContexts } from "../../src/agent-manager/project-context"
+import { ProjectRegistry, type StoredProject } from "../../src/agent-manager/project-registry"
+import { projectIdFor } from "../../src/agent-manager/project-paths"
+import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
+
+const WORKSPACE = "/repo/main"
+const PINNED = projectIdFor(WORKSPACE)
+
+function stored(id: string, trusted = false): StoredProject {
+ return {
+ id,
+ root: `/repo/${id}`,
+ order: 1,
+ trusted,
+ addedAt: new Date().toISOString(),
+ }
+}
+
+function setup(opts: { workspace?: string; enabled?: boolean; projects?: StoredProject[] } = {}) {
+ const registryProjects = opts.projects ?? []
+ const registry = {
+ list: () => registryProjects,
+ get: (id: string) => registryProjects.find((p) => p.id === id),
+ }
+ const created: string[] = []
+ const contexts = new ProjectContexts({
+ workspaceRoot: () => opts.workspace,
+ registry,
+ trusted: (id) => registry.get(id)?.trusted === true,
+ enabled: () => opts.enabled ?? false,
+ deps: {
+ log: () => {},
+ exists: () => true,
+ state: (root) => {
+ created.push(root)
+ return { root, flush: async () => {} } as unknown as WorktreeStateManager
+ },
+ },
+ })
+ return { contexts, created, registryProjects }
+}
+
+describe("ProjectContexts", () => {
+ it("derives the pinned project from the current workspace root", () => {
+ const { contexts } = setup({ workspace: WORKSPACE })
+ const active = contexts.active()
+ expect(active?.id).toBe(PINNED)
+ expect(active?.root).toBe(WORKSPACE)
+ expect(active?.pinned).toBe(true)
+ })
+
+ it("has no active project without a workspace when the flag is off", () => {
+ const { contexts } = setup({ enabled: false })
+ expect(contexts.active()).toBeUndefined()
+ })
+
+ it("falls back to the first trusted registry project without a workspace", () => {
+ const extra = stored("prj-extra", true)
+ const { contexts } = setup({ enabled: true, projects: [extra] })
+ expect(contexts.active()?.id).toBe("prj-extra")
+ })
+
+ it("rejects activating registry projects when the flag is off", () => {
+ const extra = stored("prj-extra", true)
+ const { contexts } = setup({ workspace: WORKSPACE, enabled: false, projects: [extra] })
+ expect(contexts.activate("prj-extra")).toBeUndefined()
+ expect(contexts.active()?.id).toBe(PINNED)
+ })
+
+ it("rejects activating untrusted projects", () => {
+ const extra = stored("prj-extra", false)
+ const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] })
+ expect(contexts.activate("prj-extra")).toBeUndefined()
+ expect(contexts.active()?.id).toBe(PINNED)
+ })
+
+ it("activates trusted projects without changing accordion expansion", () => {
+ const extra = stored("prj-extra", true)
+ const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] })
+ const ctx = contexts.activate("prj-extra")
+ expect(ctx?.root).toBe("/repo/prj-extra")
+ expect(contexts.active()?.id).toBe("prj-extra")
+ expect(contexts.isExpanded("prj-extra")).toBe(false)
+ })
+
+ it("keeps explicit accordion expansion unchanged after switching", () => {
+ const extra = stored("prj-extra", true)
+ const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] })
+ contexts.active()
+ contexts.expand("prj-extra")
+ contexts.activate("prj-extra")
+ expect(contexts.active()?.id).toBe("prj-extra")
+ expect(contexts.isExpanded(PINNED)).toBe(true)
+ contexts.activate(PINNED)
+ expect(contexts.isExpanded("prj-extra")).toBe(true)
+ })
+
+ it("expands without changing the active project", () => {
+ const extra = stored("prj-extra", true)
+ const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] })
+ expect(contexts.expand("prj-extra")?.id).toBe("prj-extra")
+ expect(contexts.active()?.id).toBe(PINNED)
+ expect(contexts.isExpanded("prj-extra")).toBe(true)
+ })
+
+ it("allows accordion collapse independently from active detail selection", () => {
+ const extra = stored("prj-extra", true)
+ const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] })
+ contexts.activate("prj-extra")
+ contexts.collapse("prj-extra")
+ expect(contexts.isExpanded("prj-extra")).toBe(false)
+ expect(contexts.active()?.id).toBe("prj-extra")
+ })
+
+ it("removes projects and falls back to the pinned project", async () => {
+ const extra = stored("prj-extra", true)
+ const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] })
+ contexts.activate("prj-extra")
+ await contexts.remove("prj-extra")
+ expect(contexts.get("prj-extra")).toBeUndefined()
+ expect(contexts.active()?.id).toBe(PINNED)
+ })
+
+ it("initializes each context once while concurrent callers wait", async () => {
+ const { contexts } = setup({ workspace: WORKSPACE })
+ const ctx = contexts.active()!
+ let release: (() => void) | undefined
+ const gate = new Promise((resolve) => (release = resolve))
+ let calls = 0
+ const run = async () => {
+ calls++
+ await gate
+ return { ok: true, refsFixed: 0 }
+ }
+ const first = ctx.ensureReady(run)
+ const second = ctx.ensureReady(run)
+ expect(first).toBe(second)
+ expect(calls).toBe(1)
+ release!()
+ expect((await first).current).toBe(true)
+ expect(ctx.lifecycle).toBe("ready")
+ })
+
+ it("invalidates initialization when suspended", async () => {
+ const { contexts } = setup({ workspace: WORKSPACE })
+ const ctx = contexts.active()!
+ let release: (() => void) | undefined
+ const gate = new Promise((resolve) => (release = resolve))
+ const init = ctx.ensureReady(async () => {
+ await gate
+ return { ok: true, refsFixed: 0 }
+ })
+ ctx.suspend()
+ release!()
+ expect((await init).current).toBe(false)
+ expect(ctx.lifecycle).toBe("suspended")
+ })
+
+ it("creates repository services lazily, once per context", () => {
+ const { contexts, created } = setup({ workspace: WORKSPACE })
+ expect(created).toEqual([])
+ const active = contexts.active()!
+ active.stateManager()
+ active.stateManager()
+ expect(created).toEqual([WORKSPACE])
+ expect(active.loaded).toBe(true)
+ })
+
+ it("isolates services between projects", () => {
+ const extra = stored("prj-extra", true)
+ const { contexts, created } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] })
+ contexts.active()!.stateManager()
+ contexts.activate("prj-extra")!.stateManager()
+ expect(created).toEqual([WORKSPACE, "/repo/prj-extra"])
+ })
+
+ it("re-derives the pinned project when the workspace changes", () => {
+ const ws: { root: string | undefined } = { root: undefined }
+ const { contexts } = setup({ enabled: false })
+ const dynamic = new ProjectContexts({
+ workspaceRoot: () => ws.root,
+ registry: { list: () => [], get: () => undefined },
+ trusted: () => false,
+ enabled: () => false,
+ deps: { log: () => {}, exists: () => true },
+ })
+ expect(dynamic.active()).toBeUndefined()
+ ws.root = WORKSPACE
+ expect(dynamic.syncPinned()).toBe(true)
+ expect(dynamic.active()?.id).toBe(PINNED)
+ expect(dynamic.syncPinned()).toBe(false)
+ ws.root = "/repo/other"
+ expect(dynamic.syncPinned()).toBe(true)
+ expect(dynamic.active()?.root).toBe("/repo/other")
+ })
+
+ it("snapshots pinned first with registry projects in order", () => {
+ const extra = stored("prj-extra", true)
+ const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] })
+ const list = contexts.snapshots()
+ expect(list.map((p) => p.id)).toEqual([PINNED, "prj-extra"])
+ expect(list[0]!.pinned).toBe(true)
+ expect(list[0]!.active).toBe(true)
+ expect(list[0]!.trusted).toBe(true)
+ expect(list[1]!.label).toBe("prj-extra")
+ expect(list[1]!.initialized).toBe(false)
+ })
+
+ it("hides registry projects from snapshots when the flag is off", () => {
+ const extra = stored("prj-extra", true)
+ const { contexts } = setup({ workspace: WORKSPACE, enabled: false, projects: [extra] })
+ expect(contexts.snapshots().map((p) => p.id)).toEqual([PINNED])
+ })
+
+ it("returns active ownership to pinned Local when multi-project is disabled", () => {
+ const extra = stored("prj-extra", true)
+ const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [extra] })
+ const secondary = contexts.expand("prj-extra")!
+ contexts.activate("prj-extra")
+
+ const pinned = contexts.disable()
+
+ expect(pinned?.id).toBe(PINNED)
+ expect(contexts.active()?.id).toBe(PINNED)
+ expect(contexts.isExpanded(PINNED)).toBe(true)
+ expect(contexts.isExpanded("prj-extra")).toBe(false)
+ expect(secondary.lifecycle).toBe("suspended")
+ })
+
+ it("dedupes registry entries that match the pinned project", () => {
+ const dupe = stored(PINNED, true)
+ const { contexts } = setup({ workspace: WORKSPACE, enabled: true, projects: [dupe] })
+ expect(contexts.snapshots().map((p) => p.id)).toEqual([PINNED])
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts
new file mode 100644
index 0000000000..1e5549ea72
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts
@@ -0,0 +1,195 @@
+import { describe, it, expect } from "bun:test"
+import * as fs from "fs"
+import * as os from "os"
+import * as path from "path"
+import { execFileSync } from "child_process"
+import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project-messages"
+import { ProjectRegistry } from "../../src/agent-manager/project-registry"
+import { ProjectContexts } from "../../src/agent-manager/project-context"
+import { projectIdFor } from "../../src/agent-manager/project-paths"
+import type { AgentManagerInMessage } from "../../src/agent-manager/types"
+
+const WORKSPACE = "/repo/main"
+
+function gitRepo(): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-am-msg-"))
+ execFileSync("git", ["init", "-q", dir])
+ return fs.realpathSync(dir)
+}
+
+function setup(opts: { enabled?: boolean; workspace?: string } = {}) {
+ let stored: unknown
+ let pickResult: string | undefined
+ const registry = new ProjectRegistry({
+ read: () => stored,
+ write: (value) => {
+ stored = value
+ },
+ })
+ const contexts = new ProjectContexts({
+ workspaceRoot: () => opts.workspace ?? WORKSPACE,
+ registry,
+ trusted: (id) => registry.get(id)?.trusted === true,
+ enabled: () => opts.enabled ?? true,
+ deps: { log: () => {}, exists: (dir) => fs.existsSync(dir) },
+ })
+ const calls = {
+ activate: [] as string[],
+ expand: [] as string[],
+ push: 0,
+ error: [] as string[],
+ pick: 0,
+ ready: [] as string[],
+ readyResult: { ok: true, refsFixed: 0, current: true } as { ok: boolean; refsFixed: number; current: boolean },
+ }
+ const deps: ProjectMessageDeps = {
+ registry,
+ contexts,
+ enabled: () => opts.enabled ?? true,
+ pickFolder: async () => {
+ calls.pick++
+ return pickResult
+ },
+ activate: (ctx) => calls.activate.push(ctx.id),
+ expand: (ctx) => calls.expand.push(ctx.id),
+ push: () => calls.push++,
+ error: (message) => calls.error.push(message),
+ ready: async (ctx) => {
+ calls.ready.push(ctx.id)
+ return calls.readyResult
+ },
+ log: () => {},
+ }
+ const pick = (dir: string | undefined) => {
+ pickResult = dir
+ }
+ return { registry, contexts, deps, calls, pick }
+}
+
+function msg(type: string, extra: Record = {}): AgentManagerInMessage {
+ return { type, ...extra } as unknown as AgentManagerInMessage
+}
+
+describe("handleProjectMessage", () => {
+ it("ignores non-project messages", async () => {
+ const { deps } = setup()
+ expect(await handleProjectMessage(msg("agentManager.createWorktree"), deps)).toBe(false)
+ })
+
+ it("pushes the catalog on requestProjects", async () => {
+ const { deps, calls, pick } = setup()
+ expect(await handleProjectMessage(msg("agentManager.requestProjects"), deps)).toBe(true)
+ expect(calls.push).toBe(1)
+ })
+
+ it("rejects every mutation while the experiment is disabled", async () => {
+ const { deps, calls } = setup({ enabled: false })
+ for (const m of [
+ msg("agentManager.addProject"),
+ msg("agentManager.removeProject", { projectId: "prj-x" }),
+ msg("agentManager.selectProject", { projectId: "prj-x" }),
+ msg("agentManager.setProjectExpanded", { projectId: "prj-x", expanded: true }),
+ msg("agentManager.trustProject", { projectId: "prj-x" }),
+ ]) {
+ await handleProjectMessage(m, deps)
+ }
+ expect(calls.pick).toBe(0)
+ expect(calls.activate).toEqual([])
+ expect(calls.error.length).toBe(5)
+ })
+
+ it("adds a picked git repository to the registry", async () => {
+ const repo = gitRepo()
+ const { deps, registry, calls, pick } = setup()
+ pick(repo)
+ await handleProjectMessage(msg("agentManager.addProject"), deps)
+ const id = projectIdFor(repo)
+ const project = registry.get(id)
+ expect(project?.root).toBe(repo)
+ expect(project?.trusted).toBe(false)
+ expect(calls.push).toBe(1)
+ expect(calls.error).toEqual([])
+ })
+
+ it("rejects folders outside a git repository", async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-am-nogit-"))
+ const { deps, calls, registry, pick } = setup()
+ pick(dir)
+ await handleProjectMessage(msg("agentManager.addProject"), deps)
+ expect(registry.list()).toEqual([])
+ expect(calls.error).toEqual(["The selected folder is not inside a Git repository."])
+ })
+
+ it("rejects the pinned workspace repository", async () => {
+ const repo = gitRepo()
+ const { deps, calls, pick } = setup({ workspace: repo })
+ pick(repo)
+ await handleProjectMessage(msg("agentManager.addProject"), deps)
+ expect(calls.error).toEqual(["That repository is already the workspace project."])
+ })
+
+ it("rejects duplicate registration", async () => {
+ const repo = gitRepo()
+ const { deps, calls, pick } = setup()
+ pick(repo)
+ await handleProjectMessage(msg("agentManager.addProject"), deps)
+ await handleProjectMessage(msg("agentManager.addProject"), deps)
+ expect(calls.error).toEqual(["That repository is already registered as a project."])
+ })
+
+ it("does nothing when the picker is cancelled", async () => {
+ const { deps, calls, registry, pick } = setup()
+ pick(undefined)
+ await handleProjectMessage(msg("agentManager.addProject"), deps)
+ expect(registry.list()).toEqual([])
+ expect(calls.push).toBe(0)
+ })
+
+ it("blocks selecting an untrusted project until trusted", async () => {
+ const repo = gitRepo()
+ const { deps, registry, calls, pick } = setup()
+ const id = projectIdFor(repo)
+ await registry.add({ id, root: repo })
+ await handleProjectMessage(msg("agentManager.selectProject", { projectId: id }), deps)
+ expect(calls.activate).toEqual([])
+ expect(calls.error.length).toBe(1)
+ await handleProjectMessage(msg("agentManager.trustProject", { projectId: id }), deps)
+ await handleProjectMessage(msg("agentManager.selectProject", { projectId: id }), deps)
+ expect(calls.activate).toEqual([id])
+ })
+
+ it("initializes trusted projects on expand", async () => {
+ const repo = gitRepo()
+ const { deps, registry, calls } = setup()
+ const id = projectIdFor(repo)
+ await registry.add({ id, root: repo })
+ await registry.setTrusted(id, true)
+ await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: true }), deps)
+ expect(calls.expand).toEqual([id])
+ await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: false }), deps)
+ expect(calls.expand).toEqual([id])
+ })
+
+ it("does not initialize untrusted projects on expand", async () => {
+ const repo = gitRepo()
+ const { deps, registry, calls } = setup()
+ const id = projectIdFor(repo)
+ await registry.add({ id, root: repo })
+ await handleProjectMessage(msg("agentManager.setProjectExpanded", { projectId: id, expanded: true }), deps)
+ expect(calls.expand).toEqual([])
+ })
+
+ it("removes projects without touching the pinned fallback", async () => {
+ const repo = gitRepo()
+ const { deps, registry, contexts, calls } = setup()
+ const id = projectIdFor(repo)
+ await registry.add({ id, root: repo })
+ await registry.setTrusted(id, true)
+ await handleProjectMessage(msg("agentManager.selectProject", { projectId: id }), deps)
+ await handleProjectMessage(msg("agentManager.removeProject", { projectId: id }), deps)
+ expect(registry.get(id)).toBeUndefined()
+ expect(contexts.get(id)).toBeUndefined()
+ expect(contexts.active()?.root).toBe(WORKSPACE)
+ expect(calls.activate).toEqual([id])
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/agent-project-paths.test.ts b/packages/kilo-vscode/tests/unit/agent-project-paths.test.ts
new file mode 100644
index 0000000000..a97682a564
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-project-paths.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from "bun:test"
+import * as fs from "fs"
+import * as os from "os"
+import * as path from "path"
+import {
+ canonicalizePath,
+ findTrackedBranch,
+ resolveProjectRoot,
+ samePath,
+} from "../../src/agent-manager/project-paths"
+
+describe("project-paths", () => {
+ it("canonicalizePath resolves a symlink alias to its realpath", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pp-root-"))
+ const target = fs.mkdtempSync(path.join(os.tmpdir(), "pp-target-"))
+ const alias = path.join(root, "alias")
+ fs.symlinkSync(target, alias)
+ try {
+ expect(canonicalizePath(alias)).toBe(canonicalizePath(target))
+ expect(canonicalizePath(alias)).not.toBe(alias)
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ fs.rmSync(target, { recursive: true, force: true })
+ }
+ })
+
+ it("samePath folds case on darwin/win32 and matches exactly on linux", () => {
+ expect(samePath("/x/Y", "/x/y", "darwin")).toBe(true)
+ expect(samePath("/x/Y", "/x/y", "win32")).toBe(true)
+ expect(samePath("/x/Y", "/x/y", "linux")).toBe(false)
+ expect(samePath("/x/y", "/x/y", "linux")).toBe(true)
+ })
+
+ it("findTrackedBranch matches a symlink-aliased path against realpath keys", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pp-tracked-"))
+ const target = fs.mkdtempSync(path.join(os.tmpdir(), "pp-target-"))
+ const alias = path.join(root, "alias")
+ fs.symlinkSync(target, alias)
+ try {
+ // git worktree list --porcelain realpath-resolves registration, so the
+ // tracked key is the realpath, not the lexical alias a session was
+ // registered with.
+ const tracked = new Map([[canonicalizePath(target), "branch-a"]])
+ expect(findTrackedBranch(tracked, alias)).toBe("branch-a")
+ expect(findTrackedBranch(tracked, target)).toBe("branch-a")
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ fs.rmSync(target, { recursive: true, force: true })
+ }
+ })
+
+ it("findTrackedBranch matches a lexical-keyed map by canonicalizing both sides", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pp-lexical-"))
+ const target = fs.mkdtempSync(path.join(os.tmpdir(), "pp-lexical-target-"))
+ const alias = path.join(root, "alias")
+ fs.symlinkSync(target, alias)
+ try {
+ // A map keyed by the lexical alias (as older code paths produced) must
+ // still match a probe path that canonicalizes to the same realpath.
+ const tracked = new Map([[alias, "branch-a"]])
+ expect(findTrackedBranch(tracked, target)).toBe("branch-a")
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ fs.rmSync(target, { recursive: true, force: true })
+ }
+ })
+
+ it("findTrackedBranch returns undefined for an untracked existing path", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pp-untracked-"))
+ const other = path.join(root, "other")
+ fs.mkdirSync(other, { recursive: true })
+ try {
+ const tracked = new Map([[canonicalizePath(root), "branch-a"]])
+ expect(findTrackedBranch(tracked, other)).toBeUndefined()
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ }
+ })
+
+ it("resolveProjectRoot maps a linked worktree to the primary checkout", async () => {
+ const calls = new Map([
+ ["--path-format=absolute --show-toplevel", "/repo/worktree"],
+ ["--path-format=absolute --git-dir", "/repo/.git/worktrees/feature"],
+ ["--path-format=absolute --git-common-dir", "/repo/.git"],
+ ["worktree list --porcelain -z", "worktree /repo\0HEAD abc\0\0worktree /repo/worktree\0HEAD def\0"],
+ ])
+ const root = await resolveProjectRoot("/repo/worktree", async (_cwd, args) => calls.get(args.join(" ")) ?? "")
+
+ expect(root).toBe(canonicalizePath("/repo"))
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/agent-project-pollers.test.ts b/packages/kilo-vscode/tests/unit/agent-project-pollers.test.ts
new file mode 100644
index 0000000000..f5170b41cb
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-project-pollers.test.ts
@@ -0,0 +1,175 @@
+import { describe, it, expect } from "bun:test"
+import { ProjectPollers, type PollerPair, type StatsOutMessage } from "../../src/agent-manager/project-pollers"
+import { ProjectContexts } from "../../src/agent-manager/project-context"
+import type { StoredProject } from "../../src/agent-manager/project-registry"
+import { projectIdFor } from "../../src/agent-manager/project-paths"
+import type { GitOps } from "../../src/agent-manager/GitOps"
+import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
+
+const WORKSPACE = "/repo/main"
+const PINNED = projectIdFor(WORKSPACE)
+
+function stored(id: string, trusted = true): StoredProject {
+ return { id, root: `/repo/${id}`, order: 1, trusted, addedAt: new Date().toISOString() }
+}
+
+function setup(projects: StoredProject[], opts: { enabled?: boolean } = {}) {
+ const contexts = new ProjectContexts({
+ workspaceRoot: () => WORKSPACE,
+ registry: {
+ list: () => projects,
+ get: (id) => projects.find((p) => p.id === id),
+ },
+ trusted: (id) => projects.find((p) => p.id === id)?.trusted === true,
+ enabled: () => opts.enabled ?? true,
+ deps: {
+ log: () => {},
+ exists: () => true,
+ state: () => ({ flush: async () => {} }) as unknown as WorktreeStateManager,
+ } as never,
+ })
+ return contexts
+}
+
+/** Expand a project and mark its state as initialized (as initExpanded would). */
+function expand(contexts: ProjectContexts, id: string, ready = true): void {
+ contexts.expand(id)
+ if (ready) contexts.get(id)?.stateManager()
+}
+
+interface FakePair {
+ pair: PollerPair
+ enabled: { stats: boolean; pr: boolean }
+ visible: { stats: boolean; pr: boolean }
+ stopped: { stats: boolean; pr: boolean }
+}
+
+function fakes() {
+ const posted: StatsOutMessage[] = []
+ const made = new Map()
+ const create = (ctx: { id: string }): PollerPair => {
+ const rec: FakePair = {
+ pair: {
+ stats: {
+ setEnabled: (v) => (rec.enabled.stats = v),
+ setVisible: (v) => (rec.visible.stats = v),
+ stop: () => (rec.stopped.stats = true),
+ },
+ pr: {
+ poller: {
+ setEnabled: (v) => (rec.enabled.pr = v),
+ setVisible: (v) => (rec.visible.pr = v),
+ stop: () => (rec.stopped.pr = true),
+ },
+ },
+ },
+ enabled: { stats: false, pr: false },
+ visible: { stats: true, pr: true },
+ stopped: { stats: false, pr: false },
+ }
+ made.set(ctx.id, rec)
+ return rec.pair
+ }
+ const deps = {
+ git: {} as GitOps,
+ semaphore: undefined as never,
+ localDiff: async () => [],
+ post: (msg: StatsOutMessage) => posted.push(msg),
+ openExternal: () => {},
+ visible: () => true,
+ log: () => {},
+ }
+ return { posted, made, create, deps }
+}
+
+describe("ProjectPollers", () => {
+ it("starts pollers for an expanded trusted background project", () => {
+ const extra = stored("prj-extra")
+ const contexts = setup([extra])
+ expand(contexts, "prj-extra")
+ const { made, create, deps } = fakes()
+ const pollers = new ProjectPollers(deps, (ctx) => create(ctx))
+ pollers.sync(contexts)
+ const rec = made.get("prj-extra")
+ expect(rec).toBeDefined()
+ expect(rec!.enabled).toEqual({ stats: true, pr: true })
+ })
+
+ it("does not start pollers for the active project", () => {
+ const contexts = setup([])
+ contexts.active()
+ const { made, create, deps } = fakes()
+ const pollers = new ProjectPollers(deps, (ctx) => create(ctx))
+ pollers.sync(contexts)
+ expect(made.has(PINNED)).toBe(false)
+ })
+
+ it("stops pollers when a project is collapsed", () => {
+ const extra = stored("prj-extra")
+ const contexts = setup([extra])
+ expand(contexts, "prj-extra")
+ const { made, create, deps } = fakes()
+ const pollers = new ProjectPollers(deps, (ctx) => create(ctx))
+ pollers.sync(contexts)
+ contexts.collapse("prj-extra")
+ pollers.sync(contexts)
+ const rec = made.get("prj-extra")
+ expect(rec!.stopped).toEqual({ stats: true, pr: true })
+ })
+
+ it("stops pollers when a project is removed", () => {
+ const extra = stored("prj-extra")
+ const contexts = setup([extra])
+ expand(contexts, "prj-extra")
+ const { made, create, deps } = fakes()
+ const pollers = new ProjectPollers(deps, (ctx) => create(ctx))
+ pollers.sync(contexts)
+ contexts.remove("prj-extra")
+ pollers.sync(contexts)
+ expect(made.get("prj-extra")!.stopped.stats).toBe(true)
+ })
+
+ it("skips projects whose state is not initialized yet", () => {
+ const extra = stored("prj-extra")
+ const contexts = setup([extra])
+ expand(contexts, "prj-extra", false)
+ const { made, create, deps } = fakes()
+ const pollers = new ProjectPollers(deps, (ctx) => create(ctx))
+ pollers.sync(contexts)
+ expect(made.has("prj-extra")).toBe(false)
+ })
+
+ it("does not duplicate pollers on repeated syncs", () => {
+ const extra = stored("prj-extra")
+ const contexts = setup([extra])
+ expand(contexts, "prj-extra")
+ const { made, create, deps } = fakes()
+ const pollers = new ProjectPollers(deps, (ctx) => create(ctx))
+ pollers.sync(contexts)
+ pollers.sync(contexts)
+ expect(made.size).toBe(1)
+ })
+
+ it("stops pollers for a background project that becomes active", () => {
+ const extra = stored("prj-extra")
+ const contexts = setup([extra])
+ expand(contexts, "prj-extra")
+ const { made, create, deps } = fakes()
+ const pollers = new ProjectPollers(deps, (ctx) => create(ctx))
+ pollers.sync(contexts)
+ contexts.activate("prj-extra")
+ pollers.sync(contexts)
+ expect(made.get("prj-extra")!.stopped.stats).toBe(true)
+ })
+
+ it("disposes all pollers", () => {
+ const extra = stored("prj-extra")
+ const contexts = setup([extra])
+ expand(contexts, "prj-extra")
+ const { made, create, deps } = fakes()
+ const pollers = new ProjectPollers(deps, (ctx) => create(ctx))
+ pollers.sync(contexts)
+ pollers.dispose()
+ expect(made.get("prj-extra")!.stopped).toEqual({ stats: true, pr: true })
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/agent-project-reactivate.test.ts b/packages/kilo-vscode/tests/unit/agent-project-reactivate.test.ts
new file mode 100644
index 0000000000..9053084dcd
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-project-reactivate.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "bun:test"
+import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project-context"
+import { reactivateProject } from "../../src/agent-manager/project-init"
+import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
+
+const ROOT = "/repo/main"
+
+function fakeState(): WorktreeStateManager {
+ return {
+ getWorktrees: () => [],
+ getSessions: () => [],
+ flush: async () => {},
+ } as unknown as WorktreeStateManager
+}
+
+function makeContext(): ProjectContext {
+ const deps: ProjectContextDeps = { log: () => {}, state: () => fakeState() }
+ const ctx = new ProjectContext("prj-test", ROOT, false, deps)
+ ctx.stateManager()
+ return ctx
+}
+
+describe("reactivateProject", () => {
+ it("returns false for a cold context that never initialized", () => {
+ const ctx = makeContext()
+ const pushed: string[] = []
+ expect(reactivateProject(ctx, undefined, () => pushed.push(ctx.id))).toBe(false)
+ expect(pushed).toHaveLength(0)
+ })
+
+ it("re-registers and pushes in-memory state for a ready context", async () => {
+ const ctx = makeContext()
+ await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
+ const registered = new Map()
+ const pushed: string[] = []
+ const ok = reactivateProject(
+ ctx,
+ {
+ setSessionDirectory: (id, dir) => registered.set(id, dir),
+ trackSession: () => {},
+ },
+ () => pushed.push(ctx.id),
+ )
+ expect(ok).toBe(true)
+ expect(pushed).toEqual(["prj-test"])
+ })
+
+ it("returns false after the context is suspended", async () => {
+ const ctx = makeContext()
+ await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
+ ctx.suspend()
+ expect(reactivateProject(ctx, undefined, () => {})).toBe(false)
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/agent-project-registry.test.ts b/packages/kilo-vscode/tests/unit/agent-project-registry.test.ts
new file mode 100644
index 0000000000..608a923b2f
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-project-registry.test.ts
@@ -0,0 +1,199 @@
+import { describe, it, expect } from "bun:test"
+import { ProjectRegistry, type StoredProject } from "../../src/agent-manager/project-registry"
+
+function mem() {
+ let value: unknown
+ return {
+ storage: {
+ read: () => value,
+ write: (next: unknown) => {
+ value = next
+ },
+ },
+ value: () => value,
+ }
+}
+
+/** Async storage shared by multiple registries: writes are deferred and
+ * serialized through a pending chain so interleaved mutations model real
+ * globalState.update timing. `failNextWrite()` forces the next write to reject. */
+function asyncMem() {
+ let value: unknown
+ let pending: Promise = Promise.resolve()
+ let failNext = false
+ const storage = {
+ read: () => value,
+ write: (next: unknown): Promise => {
+ const payload = next
+ const done = pending.then(() => {
+ if (failNext) {
+ failNext = false
+ throw new Error("write failed")
+ }
+ value = payload
+ })
+ pending = done.catch(() => {})
+ return done
+ },
+ }
+ return {
+ storage,
+ value: () => value,
+ failNextWrite: () => {
+ failNext = true
+ },
+ }
+}
+
+function input(id: string, root = `/repo/${id}`) {
+ return { id, root }
+}
+
+describe("ProjectRegistry", () => {
+ it("starts empty when storage is empty", () => {
+ const registry = new ProjectRegistry(mem().storage)
+ expect(registry.list()).toEqual([])
+ })
+
+ it("adds and lists projects in insertion order", async () => {
+ const registry = new ProjectRegistry(mem().storage)
+ await registry.add(input("prj-a"))
+ await registry.add(input("prj-b"))
+ const list = registry.list()
+ expect(list.map((p) => p.id)).toEqual(["prj-a", "prj-b"])
+ expect(list[0]!.order).toBe(1)
+ expect(list[1]!.order).toBe(2)
+ expect(list[0]!.trusted).toBe(false)
+ })
+
+ it("rejects duplicate ids", async () => {
+ const registry = new ProjectRegistry(mem().storage)
+ await registry.add(input("prj-a"))
+ await expect(registry.add(input("prj-a"))).rejects.toThrow("already registered")
+ })
+
+ it("persists across instances sharing storage", async () => {
+ const { storage } = mem()
+ const first = new ProjectRegistry(storage)
+ await first.add(input("prj-a"))
+ await first.setTrusted("prj-a", true)
+ const second = new ProjectRegistry(storage)
+ const project = second.get("prj-a")
+ expect(project?.trusted).toBe(true)
+ expect(project?.root).toBe("/repo/prj-a")
+ })
+
+ it("removes projects and reports unknown ids", async () => {
+ const registry = new ProjectRegistry(mem().storage)
+ await registry.add(input("prj-a"))
+ expect(await registry.remove("prj-a")).toBe(true)
+ expect(await registry.remove("prj-a")).toBe(false)
+ expect(registry.list()).toEqual([])
+ })
+
+ it("updates labels", async () => {
+ const registry = new ProjectRegistry(mem().storage)
+ await registry.add(input("prj-a"))
+ expect(await registry.setLabel("prj-a", "Backend")).toBe(true)
+ expect(registry.get("prj-a")?.label).toBe("Backend")
+ expect(await registry.setLabel("missing", "x")).toBe(false)
+ })
+
+ it("fails closed to an empty catalog for corrupt storage", async () => {
+ const { storage } = mem()
+ await storage.write({ version: 99, projects: "garbage" })
+ const registry = new ProjectRegistry(storage)
+ expect(registry.list()).toEqual([])
+ await registry.add(input("prj-a"))
+ expect(registry.list()).toHaveLength(1)
+ })
+
+ it("drops invalid and duplicate entries on load", async () => {
+ const { storage } = mem()
+ const valid: StoredProject = {
+ id: "prj-a",
+ root: "/repo/a",
+ order: 1,
+ trusted: false,
+ addedAt: new Date().toISOString(),
+ }
+ await storage.write({
+ version: 1,
+ projects: [valid, { id: "broken" }, { ...valid, order: 2 }],
+ })
+ const registry = new ProjectRegistry(storage)
+ expect(registry.list()).toEqual([valid])
+ })
+
+ it("merges interleaved writes from two instances sharing async storage", async () => {
+ const { storage } = asyncMem()
+ const a = new ProjectRegistry(storage)
+ const b = new ProjectRegistry(storage)
+ // Prime both caches as empty so they go stale once the other instance writes.
+ a.list()
+ b.list()
+ // b writes first; a's cache is now stale but a must re-read and merge.
+ await b.add(input("prj-b"))
+ await a.add(input("prj-a"))
+ // b edits a's project; a's cache is stale but must not overwrite b's trust change.
+ await b.setTrusted("prj-a", true)
+ // a edits b's project; must merge with b's trust change, not overwrite it.
+ await a.setLabel("prj-b", "B")
+ // A fresh instance reflects the true persisted catalog: no lost updates.
+ const c = new ProjectRegistry(storage)
+ expect(
+ c
+ .list()
+ .map((p) => p.id)
+ .sort(),
+ ).toEqual(["prj-a", "prj-b"])
+ expect(c.get("prj-a")?.trusted).toBe(true)
+ expect(c.get("prj-b")?.label).toBe("B")
+ })
+
+ it("serializes concurrent mutations on one instance so no update is lost", async () => {
+ const { storage } = asyncMem()
+ const a = new ProjectRegistry(storage)
+ await a.add(input("prj-x"))
+ // A concurrent add and remove of different ids must both take effect:
+ // without the queue the remove reads stale storage and clobbers the add.
+ await Promise.all([a.add(input("prj-y")), a.remove("prj-x")])
+ expect(
+ a
+ .list()
+ .map((p) => p.id)
+ .sort(),
+ ).toEqual(["prj-y"])
+ expect(
+ new ProjectRegistry(storage)
+ .list()
+ .map((p) => p.id)
+ .sort(),
+ ).toEqual(["prj-y"])
+ })
+
+ it("does not leave memory ahead of storage after a failed write", async () => {
+ const { storage, failNextWrite } = asyncMem()
+ const a = new ProjectRegistry(storage)
+ await a.add(input("prj-a"))
+ failNextWrite()
+ await expect(a.add(input("prj-b"))).rejects.toThrow("write failed")
+ // The failed add must not be visible in memory or in storage.
+ expect(a.list().map((p) => p.id)).toEqual(["prj-a"])
+ expect(new ProjectRegistry(storage).list().map((p) => p.id)).toEqual(["prj-a"])
+ // The queue is not poisoned by the failure: a later mutation still persists.
+ await a.add(input("prj-c"))
+ expect(
+ a
+ .list()
+ .map((p) => p.id)
+ .sort(),
+ ).toEqual(["prj-a", "prj-c"])
+ expect(
+ new ProjectRegistry(storage)
+ .list()
+ .map((p) => p.id)
+ .sort(),
+ ).toEqual(["prj-a", "prj-c"])
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts b/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts
new file mode 100644
index 0000000000..407f5de28e
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-project-restore.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from "bun:test"
+import { rememberTarget, restoreProjectTarget, type RestoreDeps } from "../../webview-ui/agent-manager/project-restore"
+import type { AgentManagerStateMessage } from "../../webview-ui/src/types/messages"
+
+function state(over: Partial): AgentManagerStateMessage {
+ return {
+ type: "agentManager.state",
+ projectId: "prj-a",
+ worktrees: [],
+ sessions: [],
+ ...over,
+ }
+}
+
+function deps() {
+ const calls: Record = {}
+ const record =
+ (key: string) =>
+ (...args: unknown[]) => {
+ calls[key] = [...(calls[key] ?? []), ...(args.length > 0 ? args : [true])]
+ }
+ const impl: RestoreDeps = {
+ selectLocal: () => record("local")(),
+ selectWorktree: (id) => record("worktree")(id),
+ selectSession: (id) => record("session")(id),
+ focusManaged: (wt, sid) => record("managed")(wt, sid),
+ setSelection: (id) => record("selection")(id),
+ setActivePendingId: (id) => record("pending")(id),
+ }
+ return { calls, impl }
+}
+
+describe("restoreProjectTarget", () => {
+ it("restores a persisted worktree target", () => {
+ const { calls, impl } = deps()
+ restoreProjectTarget(
+ state({
+ worktrees: [{ id: "0" } as never],
+ activeTarget: { projectId: "prj-a", kind: "worktree", worktreeId: "0" },
+ }),
+ impl,
+ )
+ expect(calls.worktree).toEqual(["0"])
+ expect(calls.pending).toEqual([undefined])
+ })
+
+ it("restores a managed worktree session target", () => {
+ const { calls, impl } = deps()
+ restoreProjectTarget(
+ state({
+ worktrees: [{ id: "0" } as never],
+ sessions: [{ id: "ses-1", worktreeId: "0" } as never],
+ activeTarget: { projectId: "prj-a", kind: "session", sessionId: "ses-1" },
+ }),
+ impl,
+ )
+ expect(calls.managed).toEqual(["0", "ses-1"])
+ })
+
+ it("restores a local session target via Local + select", () => {
+ const { calls, impl } = deps()
+ restoreProjectTarget(state({ activeTarget: { projectId: "prj-a", kind: "session", sessionId: "ses-x" } }), impl)
+ expect(calls.selection).toEqual(["local"])
+ expect(calls.session).toEqual(["ses-x"])
+ })
+
+ it("falls back to Local when the target no longer exists", () => {
+ const { calls, impl } = deps()
+ restoreProjectTarget(state({ activeTarget: { projectId: "prj-a", kind: "worktree", worktreeId: "gone" } }), impl)
+ expect(calls.local).toHaveLength(1)
+ expect(calls.worktree).toBeUndefined()
+ })
+
+ it("falls back to Local when there is no target", () => {
+ const { calls, impl } = deps()
+ restoreProjectTarget(state({}), impl)
+ expect(calls.local).toHaveLength(1)
+ })
+})
+
+describe("rememberTarget", () => {
+ it("posts a session target when a session is active", () => {
+ const posted: unknown[] = []
+ rememberTarget((msg) => posted.push(msg), "prj-a", "0", "ses-1")
+ expect(posted).toEqual([
+ {
+ type: "agentManager.rememberTarget",
+ projectId: "prj-a",
+ target: { projectId: "prj-a", kind: "session", sessionId: "ses-1" },
+ },
+ ])
+ })
+
+ it("posts a worktree target when no session is active", () => {
+ const posted: unknown[] = []
+ rememberTarget((msg) => posted.push(msg), "prj-a", "0", undefined)
+ expect(posted).toEqual([
+ {
+ type: "agentManager.rememberTarget",
+ projectId: "prj-a",
+ target: { projectId: "prj-a", kind: "worktree", worktreeId: "0" },
+ },
+ ])
+ })
+
+ it("posts a local target for the Local context", () => {
+ const posted: unknown[] = []
+ rememberTarget((msg) => posted.push(msg), "prj-a", "local", undefined)
+ expect(posted).toEqual([
+ { type: "agentManager.rememberTarget", projectId: "prj-a", target: { projectId: "prj-a", kind: "local" } },
+ ])
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/agent-project-route.test.ts b/packages/kilo-vscode/tests/unit/agent-project-route.test.ts
new file mode 100644
index 0000000000..da678d8eff
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-project-route.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, it } from "bun:test"
+import { ProjectRouteError, ProjectRouteService } from "../../src/agent-manager/project-route"
+
+describe("ProjectRouteService", () => {
+ it("resolves explicit local, worktree, and session routes", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerWorktree({ projectId: "a", worktreeId: "wt" }, "/repo/a/.kilo/wt", 1)
+ routes.registerSession({ projectId: "a", sessionId: "local" }, "/repo/a", 1)
+ routes.registerSession({ projectId: "a", sessionId: "work" }, "/repo/a/.kilo/wt", 1)
+ expect(routes.projectRoot({ projectId: "a" })).toBe("/repo/a")
+ expect(routes.worktreeDirectory({ projectId: "a", worktreeId: "wt" })).toBe("/repo/a/.kilo/wt")
+ expect(routes.sessionDirectory({ projectId: "a", sessionId: "local" })).toBe("/repo/a")
+ expect(routes.sessionDirectory({ projectId: "a", sessionId: "work" })).toBe("/repo/a/.kilo/wt")
+ })
+
+ it("inherits child session ownership", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerSession({ projectId: "a", sessionId: "parent" }, "/repo/a", 1)
+ routes.inheritSession({ projectId: "a", sessionId: "child" }, { projectId: "a", sessionId: "parent" })
+ expect(routes.sessionDirectory({ projectId: "a", sessionId: "child" })).toBe("/repo/a")
+ })
+
+ it("detects ambiguous raw session ids", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerProject("b", "/repo/b", 1)
+ routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1)
+ routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1)
+ expect(() => routes.resolveRawSession("same")).toThrow(ProjectRouteError)
+ try {
+ routes.resolveRawSession("same")
+ } catch (err) {
+ expect((err as ProjectRouteError).code).toBe("session_ambiguous")
+ }
+ })
+
+ it("invalidates routes when a project generation changes", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerSession({ projectId: "a", sessionId: "s" }, "/repo/a", 1)
+ routes.registerProject("a", "/repo/a", 2)
+ expect(() => routes.sessionDirectory({ projectId: "a", sessionId: "s" })).toThrow(ProjectRouteError)
+ })
+
+ it("uses composite UI keys", () => {
+ expect(ProjectRouteService.key({ projectId: "a", worktreeId: "same" })).not.toBe(
+ ProjectRouteService.key({ projectId: "b", worktreeId: "same" }),
+ )
+ })
+
+ describe("safe resolution (non-throwing)", () => {
+ it("trySessionDirectory returns the exact dir for an unambiguous raw id", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerSession({ projectId: "a", sessionId: "s1" }, "/repo/a", 1)
+ expect(routes.trySessionDirectory("s1")).toBe("/repo/a")
+ })
+
+ it("trySessionDirectory returns undefined for an unknown raw id", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ expect(routes.trySessionDirectory("missing")).toBeUndefined()
+ })
+
+ it("trySessionDirectory returns undefined for an ambiguous raw id (never picks one)", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerProject("b", "/repo/b", 1)
+ routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1)
+ routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1)
+ expect(routes.trySessionDirectory("same")).toBeUndefined()
+ })
+
+ it("isSessionAmbiguous is true only when a raw id maps to more than one project", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerProject("b", "/repo/b", 1)
+ routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1)
+ expect(routes.isSessionAmbiguous("same")).toBe(false)
+ routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1)
+ expect(routes.isSessionAmbiguous("same")).toBe(true)
+ expect(routes.isSessionAmbiguous("unknown")).toBe(false)
+ })
+
+ it("trySessionDirectoryFor resolves a project-qualified ref exactly", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerProject("b", "/repo/b", 1)
+ routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1)
+ routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1)
+ expect(routes.trySessionDirectoryFor({ projectId: "a", sessionId: "same" })).toBe("/repo/a")
+ expect(routes.trySessionDirectoryFor({ projectId: "b", sessionId: "same" })).toBe("/repo/b")
+ })
+
+ it("trySessionDirectoryFor returns undefined for an unknown project or session", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerSession({ projectId: "a", sessionId: "s1" }, "/repo/a", 1)
+ expect(routes.trySessionDirectoryFor({ projectId: "nope", sessionId: "s1" })).toBeUndefined()
+ expect(routes.trySessionDirectoryFor({ projectId: "a", sessionId: "missing" })).toBeUndefined()
+ })
+
+ it("unregisterSession removes one route and clears ambiguity", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerProject("b", "/repo/b", 1)
+ routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1)
+ routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1)
+ expect(routes.isSessionAmbiguous("same")).toBe(true)
+ routes.unregisterSession({ projectId: "b", sessionId: "same" })
+ expect(routes.isSessionAmbiguous("same")).toBe(false)
+ expect(routes.trySessionDirectory("same")).toBe("/repo/a")
+ })
+
+ it("unregisterSession is a no-op for an unknown ref", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ expect(() => routes.unregisterSession({ projectId: "a", sessionId: "missing" })).not.toThrow()
+ })
+
+ it("hasSession reports registered refs only", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerSession({ projectId: "a", sessionId: "s1" }, "/repo/a", 1)
+ expect(routes.hasSession({ projectId: "a", sessionId: "s1" })).toBe(true)
+ expect(routes.hasSession({ projectId: "a", sessionId: "s2" })).toBe(false)
+ })
+
+ it("unregisterProject clears all session routes for that project", () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerSession({ projectId: "a", sessionId: "s1" }, "/repo/a", 1)
+ routes.registerSession({ projectId: "a", sessionId: "s2" }, "/repo/a", 1)
+ routes.unregisterProject("a")
+ expect(routes.trySessionDirectory("s1")).toBeUndefined()
+ expect(routes.trySessionDirectory("s2")).toBeUndefined()
+ expect(routes.isSessionAmbiguous("s1")).toBe(false)
+ })
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts
new file mode 100644
index 0000000000..44c3c72c09
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts
@@ -0,0 +1,207 @@
+import { describe, it, expect } from "bun:test"
+import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project-messages"
+import { ProjectContexts, type ProjectContext, type ProjectInitResult } from "../../src/agent-manager/project-context"
+import { projectIdFor } from "../../src/agent-manager/project-paths"
+import type { AgentManagerInMessage } from "../../src/agent-manager/types"
+import type { StoredProject } from "../../src/agent-manager/project-registry"
+import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
+
+const WORKSPACE = "/repo/main"
+const PINNED = projectIdFor(WORKSPACE)
+
+function stored(id: string, trusted = true): StoredProject {
+ return { id, root: `/repo/${id}`, order: 1, trusted, addedAt: new Date().toISOString() }
+}
+
+function fakeState(persisted?: { current?: unknown }) {
+ const store = persisted ?? {}
+ return {
+ getWorktree: (id: string) => (id === "wt1" ? { path: "/repo/prj-extra/wt1" } : undefined),
+ getSession: (id: string) => (id === "sess1" ? {} : undefined),
+ getActiveTarget: () => store.current,
+ setActiveTarget: (target: unknown) => {
+ store.current = target
+ },
+ } as unknown as WorktreeStateManager
+}
+
+function setup(
+ opts: {
+ enabled?: boolean
+ workspace?: string
+ ready?: (ctx: ProjectContext) => Promise
+ state?: () => WorktreeStateManager
+ } = {},
+) {
+ const extra = "prj-extra"
+ const projects = [stored(extra, true)]
+ const registry = {
+ list: () => projects,
+ get: (id: string) => projects.find((p) => p.id === id),
+ }
+ const contexts = new ProjectContexts({
+ workspaceRoot: () => opts.workspace ?? WORKSPACE,
+ registry,
+ trusted: (id) => registry.get(id)?.trusted === true,
+ enabled: () => opts.enabled ?? true,
+ deps: {
+ log: () => {},
+ exists: () => true,
+ state: opts.state ?? (() => fakeState()),
+ },
+ })
+ const calls = {
+ activate: [] as string[],
+ expand: [] as string[],
+ push: 0,
+ error: [] as string[],
+ selected: [] as string[],
+ ready: [] as string[],
+ }
+ const readyImpl =
+ opts.ready ??
+ (async (ctx: ProjectContext) => {
+ calls.ready.push(ctx.id)
+ return ctx.ensureReady(async () => {
+ ctx.stateManager()
+ return { ok: true, refsFixed: 0 }
+ })
+ })
+ const deps: ProjectMessageDeps = {
+ registry: registry as never,
+ contexts,
+ enabled: () => opts.enabled ?? true,
+ pickFolder: async () => undefined,
+ activate: (ctx) => calls.activate.push(ctx.id),
+ expand: (ctx) => calls.expand.push(ctx.id),
+ push: () => calls.push++,
+ error: (message) => calls.error.push(message),
+ ready: readyImpl,
+ selected: (target) => calls.selected.push(target.kind === "local" ? target.projectId : target.kind),
+ log: () => {},
+ }
+ return { contexts, deps, calls, extra }
+}
+
+function activateMsg(projectId: string, extra: Record = {}): AgentManagerInMessage {
+ return {
+ type: "agentManager.activateSelection",
+ target: { projectId, kind: "local", ...extra },
+ } as unknown as AgentManagerInMessage
+}
+
+describe("activateSelection — cross-project selection", () => {
+ it("acknowledges a ready cross-project local selection without re-running completed initialization", async () => {
+ const { contexts, deps, calls, extra } = setup()
+ const ctx = contexts.expand(extra)!
+ ctx.stateManager()
+ await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
+ expect(ctx.lifecycle).toBe("ready")
+
+ calls.ready = []
+ await handleProjectMessage(activateMsg(extra), deps)
+
+ expect(contexts.active()?.id).toBe(extra)
+ expect(calls.activate).toEqual([extra])
+ expect(calls.ready).toEqual([extra])
+ expect(calls.selected).toEqual([extra])
+ expect(calls.error).toEqual([])
+ })
+
+ it("waits for readiness on a cold (never-initialized) selection", async () => {
+ const { contexts, deps, calls, extra } = setup()
+ expect(contexts.get(extra)).toBeUndefined()
+
+ await handleProjectMessage(activateMsg(extra), deps)
+
+ expect(contexts.get(extra)?.lifecycle).toBe("ready")
+ expect(contexts.active()?.id).toBe(extra)
+ expect(calls.ready).toEqual([extra])
+ expect(calls.activate).toEqual([extra])
+ expect(calls.selected).toEqual([extra])
+ })
+
+ it("falls back to local without an error toast when the target is gone", async () => {
+ const { contexts, deps, calls, extra } = setup()
+ contexts.activate(PINNED)
+ expect(contexts.active()?.id).toBe(PINNED)
+
+ await handleProjectMessage(activateMsg(extra, { kind: "worktree", worktreeId: "missing" }), deps)
+
+ // A stale target is not actionable, so the selection lands on the
+ // project's local context instead of surfacing an error.
+ expect(contexts.active()?.id).toBe(extra)
+ expect(calls.activate).toEqual([extra])
+ expect(calls.selected).toEqual([extra])
+ expect(calls.error).toEqual([])
+ })
+
+ it("does not re-run initialization for an already-ready context", async () => {
+ const { contexts, deps, calls, extra } = setup()
+ const ctx = contexts.expand(extra)!
+ ctx.stateManager()
+ await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
+ expect(ctx.lifecycle).toBe("ready")
+
+ const readyCalls: string[] = []
+ deps.ready = async (c) => {
+ readyCalls.push(c.id)
+ return { ok: true, refsFixed: 0, current: true }
+ }
+
+ await handleProjectMessage(activateMsg(extra), deps)
+ await handleProjectMessage(activateMsg(extra), deps)
+
+ expect(readyCalls).toEqual([extra, extra])
+ const initField = (ctx as unknown as { init?: unknown }).init
+ expect(initField).toBeUndefined()
+ })
+
+ it("does not reset project services when selecting within the active project", async () => {
+ const { contexts, deps, calls, extra } = setup()
+ const ctx = contexts.expand(extra)!
+ ctx.stateManager()
+ await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
+ contexts.activate(extra)
+
+ await handleProjectMessage(activateMsg(extra, { kind: "worktree", worktreeId: "wt1" }), deps)
+
+ expect(contexts.active()?.id).toBe(extra)
+ expect(calls.activate).toEqual([])
+ expect(calls.selected).toEqual(["worktree"])
+ expect(calls.error).toEqual([])
+ })
+
+ it("restores the persisted target when the selection asks for it", async () => {
+ const persisted = { current: undefined as unknown }
+ const { contexts, deps, calls, extra } = setup({ state: () => fakeState(persisted) })
+ const ctx = contexts.expand(extra)!
+ ctx.stateManager()
+ await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
+ persisted.current = { projectId: extra, kind: "worktree", worktreeId: "wt1" }
+
+ await handleProjectMessage(
+ { type: "agentManager.activateSelection", target: { projectId: extra, kind: "local" }, restore: true } as never,
+ deps,
+ )
+
+ expect(calls.selected).toEqual(["worktree"])
+ expect(calls.error).toEqual([])
+ })
+
+ it("falls back to the requested target when the persisted one is gone", async () => {
+ const persisted = { current: { projectId: "prj-extra", kind: "worktree", worktreeId: "missing" } as unknown }
+ const { contexts, deps, calls, extra } = setup({ state: () => fakeState(persisted) })
+ const ctx = contexts.expand(extra)!
+ ctx.stateManager()
+ await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
+
+ await handleProjectMessage(
+ { type: "agentManager.activateSelection", target: { projectId: extra, kind: "local" }, restore: true } as never,
+ deps,
+ )
+
+ expect(calls.selected).toEqual([extra])
+ expect(calls.error).toEqual([])
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/agent-project-sessions.test.ts b/packages/kilo-vscode/tests/unit/agent-project-sessions.test.ts
new file mode 100644
index 0000000000..c4ecf0b73a
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-project-sessions.test.ts
@@ -0,0 +1,436 @@
+import { describe, expect, it } from "bun:test"
+import type { Session } from "@kilocode/sdk/v2/client"
+import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project-context"
+import type { Worktree, ManagedSession, WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
+import {
+ collectProjectSessions,
+ projectDirectories,
+ pushProjectSessions,
+ registerProjectSessions,
+ unregisterProjectRoutes,
+ type ProjectSessionListing,
+} from "../../src/agent-manager/project-init"
+import { ProjectRouteService, type SessionRef, type WorktreeRef } from "../../src/agent-manager/project-route"
+
+const ROOT = "/repo/main"
+const WT_PATH = "/repo/main/.kilo/worktrees/fix"
+const OTHER = "/other/project"
+
+function mkSession(id: string, dir: string): Session {
+ return {
+ id,
+ slug: id,
+ projectID: "prj-test",
+ directory: dir,
+ title: `Session ${id}`,
+ version: "1",
+ time: { created: 1000, updated: 2000 },
+ }
+}
+
+/** Build a fake WorktreeStateManager with just the queries project-init uses. */
+function fakeState(worktrees: Worktree[], sessions: ManagedSession[] = []): WorktreeStateManager {
+ const wtMap = new Map(worktrees.map((w) => [w.id, w]))
+ return {
+ getWorktrees: () => worktrees,
+ getWorktree: (id: string) => wtMap.get(id),
+ getSessions: () => sessions,
+ flush: async () => {},
+ } as unknown as WorktreeStateManager
+}
+
+function makeContext(root: string, state: WorktreeStateManager, id = "prj-test"): ProjectContext {
+ const deps: ProjectContextDeps = {
+ log: () => {},
+ state: () => state,
+ }
+ const ctx = new ProjectContext(id, root, false, deps)
+ // Instantiate the lazy state so peekState() returns the fake.
+ ctx.stateManager()
+ return ctx
+}
+
+/** A recording ProjectSessionListing: serves sessions by directory and records registrations. */
+function recordingListing(byDir: Record Session[] | Promise)>): {
+ listing: ProjectSessionListing
+ calls: string[]
+ registered: Map
+} {
+ const calls: string[] = []
+ const registered = new Map()
+ const listing: ProjectSessionListing = {
+ listSessions: async (dir) => {
+ calls.push(dir)
+ const value = byDir[dir]
+ const resolved = typeof value === "function" ? await value() : (value ?? [])
+ return resolved
+ },
+ setSessionDirectory: (id, dir) => {
+ registered.set(id, dir)
+ },
+ }
+ return { listing, calls, registered }
+}
+
+/**
+ * A recording listing that also forwards route registrations to a real
+ * ProjectRouteService, so tests can assert exact-route behavior end-to-end
+ * through the project-init functions. Includes trackSession so it satisfies
+ * registerProjectSessions' full parameter shape.
+ */
+function routeListing(
+ routes: ProjectRouteService,
+ byDir: Record Session[] | Promise)>,
+ projectId: string,
+ generation: number,
+): {
+ listing: ProjectSessionListing & { trackSession(id: string): void }
+ calls: string[]
+ registered: Map
+ tracked: string[]
+} {
+ const calls: string[] = []
+ const registered = new Map()
+ const tracked: string[] = []
+ const listing: ProjectSessionListing & { trackSession(id: string): void } = {
+ listSessions: async (dir) => {
+ calls.push(dir)
+ const value = byDir[dir]
+ const resolved = typeof value === "function" ? await value() : (value ?? [])
+ return resolved
+ },
+ setSessionDirectory: (id, dir) => {
+ registered.set(id, dir)
+ },
+ trackSession: (id) => tracked.push(id),
+ registerProjectRoute: (ref, root, gen) => routes.registerProject(ref.projectId, root, gen),
+ unregisterProjectRoute: (id) => routes.unregisterProject(id),
+ registerWorktreeRoute: (ref, dir, gen) => routes.registerWorktree(ref, dir, gen),
+ registerSessionRoute: (ref, dir, gen) => routes.registerSession(ref, dir, gen),
+ unregisterSessionRoute: (ref) => routes.unregisterSession(ref),
+ }
+ void projectId
+ void generation
+ return { listing, calls, registered, tracked }
+}
+
+describe("Agent Manager per-project session discovery", () => {
+ it("projectDirectories is exactly ctx.root plus this context's worktree paths", () => {
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const ctx = makeContext(ROOT, fakeState([wt]))
+ expect(projectDirectories(ctx).sort()).toEqual([ROOT, WT_PATH].sort())
+ })
+
+ it("projectDirectories omits worktrees with empty paths and dedupes ctx.root", () => {
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: ROOT, parentBranch: "main", createdAt: "" }
+ const empty: Worktree = { id: "wt-2", branch: "fix2", path: "", parentBranch: "main", createdAt: "" }
+ const ctx = makeContext(ROOT, fakeState([wt, empty]))
+ expect(projectDirectories(ctx)).toEqual([ROOT])
+ })
+
+ it("lists root and worktree sessions and registers each with its exact directory", async () => {
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const ctx = makeContext(ROOT, fakeState([wt]))
+ const rootSession = mkSession("ses-root", ROOT)
+ const wtSession = mkSession("ses-wt", WT_PATH)
+ const { listing, calls, registered } = recordingListing({
+ [ROOT]: [rootSession],
+ [WT_PATH]: [wtSession],
+ })
+
+ const out = await collectProjectSessions(ctx, listing)
+
+ expect(out.map((s) => s.id).sort()).toEqual(["ses-root", "ses-wt"].sort())
+ expect(calls.sort()).toEqual([ROOT, WT_PATH].sort())
+ expect(registered.get("ses-root")).toBe(ROOT)
+ expect(registered.get("ses-wt")).toBe(WT_PATH)
+ // sessionToWebview round-trip
+ expect(out.find((s) => s.id === "ses-root")?.title).toBe("Session ses-root")
+ })
+
+ it("does not list or include sessions from unrelated-project directories", async () => {
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const ctx = makeContext(ROOT, fakeState([wt]))
+ const foreign = mkSession("ses-foreign", OTHER)
+ const rootSession = mkSession("ses-root", ROOT)
+ // The foreign directory is present in the listing map but must never be queried,
+ // because ctx only owns ROOT + its own worktree paths.
+ const { listing, calls } = recordingListing({
+ [ROOT]: [rootSession],
+ [WT_PATH]: [],
+ [OTHER]: [foreign],
+ })
+
+ const out = await collectProjectSessions(ctx, listing)
+
+ expect(calls).not.toContain(OTHER)
+ expect(out.map((s) => s.id)).toEqual(["ses-root"])
+ })
+
+ it("retains valid sessions when one directory listing fails", async () => {
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const ctx = makeContext(ROOT, fakeState([wt]))
+ const wtSession = mkSession("ses-wt", WT_PATH)
+ const { listing, registered } = recordingListing({
+ [ROOT]: () => {
+ throw new Error("backend not connected")
+ },
+ [WT_PATH]: [wtSession],
+ })
+
+ const out = await collectProjectSessions(ctx, listing)
+
+ expect(out.map((s) => s.id)).toEqual(["ses-wt"])
+ expect(registered.get("ses-wt")).toBe(WT_PATH)
+ })
+
+ it("retains valid sessions when listSessions rejects (promise) for one directory", async () => {
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const ctx = makeContext(ROOT, fakeState([wt]))
+ const wtSession = mkSession("ses-wt", WT_PATH)
+ const calls: string[] = []
+ const registered = new Map()
+ const listing: ProjectSessionListing = {
+ listSessions: async (dir) => {
+ calls.push(dir)
+ if (dir === ROOT) throw new Error("boom")
+ return [wtSession]
+ },
+ setSessionDirectory: (id, dir) => registered.set(id, dir),
+ }
+
+ const out = await collectProjectSessions(ctx, listing)
+
+ expect(out.map((s) => s.id)).toEqual(["ses-wt"])
+ expect(registered.get("ses-wt")).toBe(WT_PATH)
+ })
+
+ it("returns [] when the SessionProvider has no listSessions", async () => {
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const ctx = makeContext(ROOT, fakeState([wt]))
+ const listing: ProjectSessionListing = { setSessionDirectory: () => {} }
+
+ const out = await collectProjectSessions(ctx, listing)
+
+ expect(out).toEqual([])
+ })
+
+ it("dedupes a session reported under two directories, keeping the first directory", async () => {
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const ctx = makeContext(ROOT, fakeState([wt]))
+ const shared = mkSession("ses-shared", ROOT)
+ const { listing, registered } = recordingListing({
+ [ROOT]: [shared],
+ [WT_PATH]: [shared],
+ })
+
+ const out = await collectProjectSessions(ctx, listing)
+
+ expect(out.map((s) => s.id)).toEqual(["ses-shared"])
+ // ctx.root is listed first (insertion order of the Set), so it wins.
+ expect(registered.get("ses-shared")).toBe(ROOT)
+ })
+
+ it("registerProjectSessions preserves managed placement (worktree session -> worktree path, local -> root)", () => {
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const managed: ManagedSession[] = [
+ { id: "ses-wt", worktreeId: "wt-1", createdAt: "" },
+ { id: "ses-local", worktreeId: null, createdAt: "" },
+ ]
+ const ctx = makeContext(ROOT, fakeState([wt], managed))
+ const registered = new Map()
+ const tracked: string[] = []
+ registerProjectSessions(ctx, {
+ setSessionDirectory: (id, dir) => registered.set(id, dir),
+ trackSession: (id) => tracked.push(id),
+ })
+
+ expect(registered.get("ses-wt")).toBe(WT_PATH)
+ expect(registered.get("ses-local")).toBe(ROOT)
+ expect(tracked.sort()).toEqual(["ses-local", "ses-wt"].sort())
+ })
+
+ it("registerProjectSessions falls back to ctx.root when a managed worktree is gone", () => {
+ const managed: ManagedSession[] = [{ id: "ses-orphan", worktreeId: "wt-gone", createdAt: "" }]
+ const ctx = makeContext(ROOT, fakeState([], managed))
+ const registered = new Map()
+ registerProjectSessions(ctx, {
+ setSessionDirectory: (id, dir) => registered.set(id, dir),
+ trackSession: () => {},
+ })
+
+ expect(registered.get("ses-orphan")).toBe(ROOT)
+ })
+
+ it("pushProjectSessions posts one projectSessions message with the collected ids", async () => {
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const ctx = makeContext(ROOT, fakeState([wt]))
+ const { listing } = recordingListing({
+ [ROOT]: [mkSession("ses-root", ROOT)],
+ [WT_PATH]: [mkSession("ses-wt", WT_PATH)],
+ })
+ const posted: Array<{ type: string; projectId: string; sessions: { id: string }[] }> = []
+
+ await pushProjectSessions(ctx, listing, (msg) => posted.push(msg))
+
+ expect(posted).toHaveLength(1)
+ expect(posted[0]!.type).toBe("agentManager.projectSessions")
+ expect(posted[0]!.projectId).toBe("prj-test")
+ expect(posted[0]!.sessions.map((s) => s.id).sort()).toEqual(["ses-root", "ses-wt"].sort())
+ })
+
+ it("pushProjectSessions posts nothing when the context is disposed", async () => {
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const ctx = makeContext(ROOT, fakeState([wt]))
+ const { listing } = recordingListing({ [ROOT]: [mkSession("ses-root", ROOT)] })
+ const posted: unknown[] = []
+ await ctx.dispose()
+ await pushProjectSessions(ctx, listing, (msg) => posted.push(msg))
+ expect(posted).toHaveLength(0)
+ })
+
+ it("pushProjectSessions re-posts the cached list while the previous listing is fresh", async () => {
+ const ctx = makeContext(ROOT, fakeState([]))
+ const { listing, calls } = recordingListing({ [ROOT]: [mkSession("ses-root", ROOT)] })
+ const posted: Array<{ sessions: Array<{ id: string }> }> = []
+
+ await pushProjectSessions(ctx, listing, (msg) => posted.push(msg))
+ expect(posted).toHaveLength(1)
+ expect(calls).toHaveLength(1)
+
+ // Fresh skip: no new backend listing, but the cached list is re-posted so
+ // a webview that mounted after the first push still learns the sessions.
+ await pushProjectSessions(ctx, listing, (msg) => posted.push(msg))
+ expect(posted).toHaveLength(2)
+ expect(posted[1]!.sessions.map((s) => s.id)).toEqual(["ses-root"])
+ expect(calls).toHaveLength(1)
+ })
+
+ it("upsertSession makes a new session visible without a backend listing", async () => {
+ const ctx = makeContext(ROOT, fakeState([]))
+ const { listing } = recordingListing({ [ROOT]: [mkSession("ses-root", ROOT)] })
+ const posted: Array<{ sessions: Array<{ id: string }> }> = []
+
+ await pushProjectSessions(ctx, listing, (msg) => posted.push(msg))
+ ctx.upsertSession({
+ id: "ses-new",
+ parentID: null,
+ title: "new",
+ createdAt: "",
+ updatedAt: "",
+ revert: null,
+ summary: null,
+ worktreeId: null,
+ })
+ expect(ctx.hasLiveSession("ses-new")).toBe(true)
+
+ // The next push (still fresh) includes the upserted session from the cache.
+ ctx.invalidateSessions()
+ const { listing: listing2, calls } = recordingListing({
+ [ROOT]: [mkSession("ses-root", ROOT), mkSession("ses-new", ROOT)],
+ })
+ await pushProjectSessions(ctx, listing2, (msg) => posted.push(msg))
+ expect(
+ posted
+ .at(-1)!
+ .sessions.map((s) => s.id)
+ .sort(),
+ ).toEqual(["ses-new", "ses-root"])
+ expect(calls).toHaveLength(1)
+ })
+})
+
+describe("Agent Manager route registration during project-init", () => {
+ it("registerProjectSessions registers the project root, worktrees, and managed session routes", () => {
+ const routes = new ProjectRouteService()
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const managed: ManagedSession[] = [
+ { id: "ses-wt", worktreeId: "wt-1", createdAt: "" },
+ { id: "ses-local", worktreeId: null, createdAt: "" },
+ ]
+ const ctx = makeContext(ROOT, fakeState([wt], managed))
+ const { listing } = routeListing(routes, {}, ctx.id, ctx.generation)
+
+ registerProjectSessions(ctx, listing)
+
+ // Project root route
+ expect(routes.projectRoot({ projectId: ctx.id })).toBe(ROOT)
+ // Worktree route
+ expect(routes.worktreeDirectory({ projectId: ctx.id, worktreeId: "wt-1" })).toBe(WT_PATH)
+ // Session routes resolve exactly
+ const wtRef: SessionRef = { projectId: ctx.id, sessionId: "ses-wt" }
+ const localRef: SessionRef = { projectId: ctx.id, sessionId: "ses-local" }
+ expect(routes.sessionDirectory(wtRef)).toBe(WT_PATH)
+ expect(routes.sessionDirectory(localRef)).toBe(ROOT)
+ })
+
+ it("collectProjectSessions registers routes for every live session and worktree", async () => {
+ const routes = new ProjectRouteService()
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const ctx = makeContext(ROOT, fakeState([wt]))
+ const rootSession = mkSession("ses-root", ROOT)
+ const wtSession = mkSession("ses-wt", WT_PATH)
+ const { listing } = routeListing(routes, { [ROOT]: [rootSession], [WT_PATH]: [wtSession] }, ctx.id, ctx.generation)
+
+ await collectProjectSessions(ctx, listing)
+
+ expect(routes.projectRoot({ projectId: ctx.id })).toBe(ROOT)
+ const wtDirRef: WorktreeRef = { projectId: ctx.id, worktreeId: "wt-1" }
+ expect(routes.worktreeDirectory(wtDirRef)).toBe(WT_PATH)
+ expect(routes.sessionDirectory({ projectId: ctx.id, sessionId: "ses-root" })).toBe(ROOT)
+ expect(routes.sessionDirectory({ projectId: ctx.id, sessionId: "ses-wt" })).toBe(WT_PATH)
+ })
+
+ it("same raw session id in two projects is ambiguous after both register", () => {
+ const routes = new ProjectRouteService()
+ const ctxA = makeContext("/repo/a", fakeState([]), "prj-a")
+ const ctxB = makeContext("/repo/b", fakeState([]), "prj-b")
+ const listingA = routeListing(routes, {}, ctxA.id, ctxA.generation).listing
+ const listingB = routeListing(routes, {}, ctxB.id, ctxB.generation).listing
+
+ // Register each project root first (as registerProjectRoutes would),
+ // then register the same raw session id in both projects.
+ listingA.registerProjectRoute!({ projectId: ctxA.id }, "/repo/a", ctxA.generation)
+ listingB.registerProjectRoute!({ projectId: ctxB.id }, "/repo/b", ctxB.generation)
+ listingA.registerSessionRoute!({ projectId: ctxA.id, sessionId: "same" }, "/repo/a", ctxA.generation)
+ listingB.registerSessionRoute!({ projectId: ctxB.id, sessionId: "same" }, "/repo/b", ctxB.generation)
+
+ expect(routes.isSessionAmbiguous("same")).toBe(true)
+ expect(routes.trySessionDirectory("same")).toBeUndefined()
+ // Project-qualified refs still resolve exactly.
+ expect(routes.trySessionDirectoryFor({ projectId: ctxA.id, sessionId: "same" })).toBe("/repo/a")
+ expect(routes.trySessionDirectoryFor({ projectId: ctxB.id, sessionId: "same" })).toBe("/repo/b")
+ })
+
+ it("unregisterProjectRoutes clears all routes for a project", () => {
+ const routes = new ProjectRouteService()
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const managed: ManagedSession[] = [{ id: "ses-wt", worktreeId: "wt-1", createdAt: "" }]
+ const ctx = makeContext(ROOT, fakeState([wt], managed))
+ const { listing } = routeListing(routes, {}, ctx.id, ctx.generation)
+
+ registerProjectSessions(ctx, listing)
+ expect(routes.hasSession({ projectId: ctx.id, sessionId: "ses-wt" })).toBe(true)
+
+ unregisterProjectRoutes(ctx, listing)
+ expect(routes.hasSession({ projectId: ctx.id, sessionId: "ses-wt" })).toBe(false)
+ expect(routes.trySessionDirectory("ses-wt")).toBeUndefined()
+ })
+
+ it("registerProjectSessions is a no-op when the listing has no route methods", () => {
+ const routes = new ProjectRouteService()
+ const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
+ const managed: ManagedSession[] = [{ id: "ses-wt", worktreeId: "wt-1", createdAt: "" }]
+ const ctx = makeContext(ROOT, fakeState([wt], managed))
+ // Plain listing without route methods — must not throw.
+ const registered = new Map()
+ const listing = {
+ setSessionDirectory: (id: string, dir: string) => registered.set(id, dir),
+ trackSession: () => {},
+ }
+ registerProjectSessions(ctx, listing)
+ expect(registered.get("ses-wt")).toBe(WT_PATH)
+ // No routes were registered.
+ expect(routes.trySessionDirectory("ses-wt")).toBeUndefined()
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/config-bindings.test.ts b/packages/kilo-vscode/tests/unit/config-bindings.test.ts
new file mode 100644
index 0000000000..fbb1327aaf
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/config-bindings.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "bun:test"
+import { ConfigBindings, type ConfigProject } from "../../src/kilo-provider/config-bindings"
+
+const { KiloProvider } = await import("../../src/KiloProvider")
+
+const target = {
+ scope: "project" as const,
+ path: "/repo/.kilo/kilo.jsonc",
+ revision: "revision",
+ exists: false,
+ writable: true,
+ raw: {},
+}
+
+describe("ConfigBindings", () => {
+ it("keeps the read-time directory and target immutable", () => {
+ const bindings = new ConfigBindings()
+ const binding = bindings.create({
+ connection: 1,
+ scope: "project",
+ directory: "/repo/a",
+ target,
+ project: { id: "a", root: "/repo/a", generation: 2, pinned: false },
+ })
+
+ expect(bindings.get(binding.id, 1, () => true)).toMatchObject({
+ directory: "/repo/a",
+ target: { path: target.path, revision: target.revision },
+ })
+ })
+
+ it("expires on reconnect, trust revocation, removal, or successful save", () => {
+ const bindings = new ConfigBindings()
+ const project: ConfigProject = { id: "a", root: "/repo/a", generation: 2, pinned: false }
+ const binding = bindings.create({ connection: 1, scope: "project", directory: project.root, target, project })
+
+ expect(bindings.get(binding.id, 2, () => true)).toBeUndefined()
+ expect(bindings.get(binding.id, 1, () => false)).toBeUndefined()
+ expect(bindings.get(binding.id, 1, () => true)).toBeDefined()
+ bindings.consume(binding.id)
+ expect(bindings.get(binding.id, 1, () => true)).toBeUndefined()
+ })
+
+ it("expires retained-panel bindings when the selected project changes", () => {
+ const provider = new KiloProvider({} as never, {} as never, undefined, { projectDirectory: "/repo/a" })
+ const internal = provider as unknown as { configBindings: ConfigBindings; connectionGeneration: number }
+ const binding = internal.configBindings.create({
+ connection: internal.connectionGeneration,
+ scope: "global",
+ directory: "/repo/a",
+ target: { ...target, scope: "global" },
+ })
+
+ provider.setProjectDirectory("/repo/b")
+
+ expect(internal.configBindings.get(binding.id, internal.connectionGeneration, () => true)).toBeUndefined()
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/config-scope.test.ts b/packages/kilo-vscode/tests/unit/config-scope.test.ts
index eca588a5d1..80bd762552 100644
--- a/packages/kilo-vscode/tests/unit/config-scope.test.ts
+++ b/packages/kilo-vscode/tests/unit/config-scope.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"
import { splitConfigByScope } from "../../webview-ui/src/utils/config-scope"
describe("splitConfigByScope", () => {
- it("writes indexing enablement to project config only", () => {
+ it("keeps indexing configuration out of project config", () => {
const split = splitConfigByScope({
indexing: {
enabled: true,
@@ -10,8 +10,8 @@ describe("splitConfigByScope", () => {
},
})
- expect(split.global).toEqual({ indexing: { provider: "ollama" } })
- expect(split.project).toEqual({ indexing: { enabled: true } })
+ expect(split.global).toEqual({ indexing: { enabled: true, provider: "ollama" } })
+ expect(split.project).toEqual({})
})
it("writes indexing provider settings to global config", () => {
@@ -25,14 +25,6 @@ describe("splitConfigByScope", () => {
expect(split.project).toEqual({})
})
- it("can write indexing enablement to global config through a global draft", () => {
- const split = splitConfigByScope({ username: "marius" })
- const draft = { indexing: { enabled: true } }
-
- expect({ ...split.global, ...draft }).toEqual({ username: "marius", indexing: { enabled: true } })
- expect(split.project).toEqual({})
- })
-
it("writes the speech-to-text model setting to global config", () => {
const split = splitConfigByScope({
experimental: {
diff --git a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts
index 0afb8b9254..c62663414f 100644
--- a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts
+++ b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts
@@ -83,6 +83,54 @@ describe("GitOps", () => {
})
describe("GitStatsPoller", () => {
+ it("keeps mutual exclusion when a stale fetch finishes after a restart", async () => {
+ let calls = 0
+ let running = 0
+ let max = 0
+ let gate: (() => void) | undefined
+ const gated = new Promise((resolve) => {
+ gate = resolve
+ })
+ const localDiff = async () => {
+ calls += 1
+ running += 1
+ max = Math.max(max, running)
+ if (calls === 1) await gated
+ running -= 1
+ return [{ ...diff(1, 0)[0], stamp: `s${calls}` }]
+ }
+ const poller = new GitStatsPoller({
+ getWorktrees: () => [],
+ getWorkspaceRoot: () => "/tmp",
+ localDiff,
+ onStats: () => undefined,
+ onLocalStats: () => undefined,
+ log: () => undefined,
+ intervalMs: 5,
+ git: gitOps(async (args) => {
+ if (args[0] === "rev-parse" && args[2] === "HEAD") return "main"
+ if (args[0] === "symbolic-ref") return "origin/main"
+ if (args[0] === "rev-list") return "0 0"
+ return ""
+ }),
+ })
+
+ poller.setEnabled(true)
+ await waitFor(() => calls === 1)
+ // Restart while the first fetch is in flight, then let it finish. The
+ // stale finally must not clear the busy flag owned by the new poll.
+ poller.stop()
+ poller.setEnabled(true)
+ await waitFor(() => calls === 2)
+ gate?.()
+ await waitFor(() => calls >= 3)
+ poller.stop()
+
+ // The gated first fetch and the restarted poll overlapped by
+ // construction; no further overlap may occur after the restart.
+ expect(max).toBeLessThanOrEqual(2)
+ })
+
it("does not overlap polling runs", async () => {
let running = 0
let max = 0
@@ -279,6 +327,57 @@ describe("GitStatsPoller", () => {
expect(emitted[0]?.map((item) => item.worktreeId)).toEqual(["a"])
})
+ it("treats symlink-aliased worktree paths as present via realpath canonicalization", async () => {
+ // git worktree list --porcelain realpath-resolves worktree registration, so
+ // a worktree registered through a symlink alias (e.g. /tmp -> /private/tmp
+ // on macOS) is reported under its realpath. The probe must canonicalize the
+ // session's lexical alias the same way, or a real worktree is marked missing
+ // and its stats are excluded.
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "gsp-symlink-"))
+ const real = fs.mkdtempSync(path.join(os.tmpdir(), "gsp-symlink-real-"))
+ const alias = path.join(root, "alias")
+ fs.symlinkSync(real, alias)
+
+ const presence: WorktreePresenceResult[] = []
+ const calls: string[] = []
+ const emitted: Array> = []
+
+ const poller = new GitStatsPoller({
+ getWorktrees: () => [{ ...worktree("a"), path: alias }],
+ getWorkspaceRoot: () => root,
+ localDiff: async (dir) => {
+ calls.push(dir)
+ return diff(3, 2)
+ },
+ onStats: (stats) => emitted.push(stats),
+ onLocalStats: () => undefined,
+ onWorktreePresence: (result) => presence.push(result),
+ log: () => undefined,
+ intervalMs: 5,
+ git: gitOps(async (args) => {
+ if (args[0] === "worktree") {
+ return `worktree ${real}\nbranch refs/heads/branch-a\n`
+ }
+ if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "origin/main"
+ if (args[0] === "rev-list") return "1"
+ return ""
+ }),
+ })
+
+ poller.setEnabled(true)
+ await waitFor(() => presence.length >= 1 && emitted.length >= 1)
+ poller.stop()
+ fs.rmSync(root, { recursive: true, force: true })
+ fs.rmSync(real, { recursive: true, force: true })
+
+ expect(presence[0]).toEqual({
+ worktrees: [{ worktreeId: "a", missing: false, branch: "branch-a" }],
+ degraded: false,
+ })
+ expect(calls.some((dir) => dir === alias)).toBe(true)
+ expect(emitted[0]?.map((item) => item.worktreeId)).toEqual(["a"])
+ })
+
it("preserves local stats when diff fails after initial success", async () => {
let diffCalls = 0
const emitted: Array<{
diff --git a/packages/kilo-vscode/tests/unit/indexing-consent.test.ts b/packages/kilo-vscode/tests/unit/indexing-consent.test.ts
new file mode 100644
index 0000000000..bc8e5d504c
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/indexing-consent.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "bun:test"
+import { IndexingConsentStore } from "../../src/indexing-consent"
+
+describe("IndexingConsentStore", () => {
+ it("isolates consent by canonical project id", async () => {
+ let value: unknown
+ const store = new IndexingConsentStore(
+ {
+ read: () => value,
+ write: (next) => {
+ value = next
+ },
+ },
+ async (dir) => (dir.includes("alias") ? "/repo/a" : dir),
+ )
+ const first = await store.project("/repo/a")
+ const alias = await store.project("/alias/a")
+ const second = await store.project("/repo/b")
+
+ await store.set(first.id, true)
+
+ expect(alias.id).toBe(first.id)
+ expect(store.enabled(alias.id)).toBe(true)
+ expect(store.enabled(second.id)).toBe(false)
+ })
+
+ it("serializes concurrent updates without dropping another project", async () => {
+ let value: unknown
+ const store = new IndexingConsentStore(
+ {
+ read: () => value,
+ write: async (next) => {
+ await Promise.resolve()
+ value = next
+ },
+ },
+ async (dir) => dir,
+ )
+ const first = await store.project("/repo/a")
+ const second = await store.project("/repo/b")
+
+ await Promise.all([store.set(first.id, true), store.set(second.id, true)])
+
+ expect(store.enabled(first.id)).toBe(true)
+ expect(store.enabled(second.id)).toBe(true)
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts
index 31ed93fd4f..bbe7591840 100644
--- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts
+++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts
@@ -23,11 +23,52 @@ type Internals = {
fetchAndSendCommands: () => Promise
fetchAndSendNotifications: () => Promise
fetchAndSendIndexingStatus: () => Promise
+ connectionGeneration: number
+ configBindings: {
+ create: (input: unknown) => { id: string }
+ }
+}
+
+function binding(internal: Internals, scope: "global" | "project") {
+ return internal.configBindings.create({
+ connection: internal.connectionGeneration,
+ scope,
+ directory: "/repo",
+ target: {
+ scope,
+ path: scope === "global" ? "/config/kilo.jsonc" : "/repo/.kilo/kilo.jsonc",
+ revision: `${scope}-revision`,
+ exists: false,
+ writable: true,
+ raw: {},
+ },
+ })
}
function createConnection() {
let drains = 0
const patches: unknown[] = []
+ const snapshot = {
+ effective: {},
+ targets: {
+ global: {
+ scope: "global",
+ path: "/config/kilo.jsonc",
+ revision: "global-next",
+ exists: true,
+ writable: true,
+ raw: {},
+ },
+ project: {
+ scope: "project",
+ path: "/repo/.kilo/kilo.jsonc",
+ revision: "project-next",
+ exists: true,
+ writable: true,
+ raw: {},
+ },
+ },
+ }
const client = {
global: {
config: {
@@ -38,10 +79,10 @@ function createConnection() {
config: {
get: async () => ({ data: {} }),
update: async () => ({ data: {} }),
- overlay: async () => ({ data: { project: {} } }),
+ overlay: async () => ({ data: { project: {}, targets: snapshot.targets } }),
overlayUpdate: async (patch: unknown) => {
patches.push(patch)
- return { data: {} }
+ return { data: snapshot }
},
},
}
@@ -105,7 +146,7 @@ describe("KiloProvider indexing refresh", () => {
await internal.handleUpdateConfig({})
- expect(conn.drains()).toBe(1)
+ expect(conn.drains()).toBe(0)
expect(indexing).toBe(0)
})
@@ -118,8 +159,9 @@ describe("KiloProvider indexing refresh", () => {
internal.fetchAndSendProviders = async () => {
calls += 1
}
+ const global = binding(internal, "global")
- await internal.handleUpdateConfig({ hide_prompt_training_models: true })
+ await internal.handleUpdateConfig({ hide_prompt_training_models: true }, {}, [], [], global.id)
expect(calls).toBe(1)
})
@@ -129,29 +171,83 @@ describe("KiloProvider indexing refresh", () => {
const provider = new KiloProvider({} as never, conn.service as never)
const internal = provider as unknown as Internals
internal.connectionState = "connected"
+ const global = binding(internal, "global")
+ const project = binding(internal, "project")
await internal.handleUpdateConfig(
{ indexing: { qdrant: { apiKey: undefined } } },
{ indexing: { searchMinScore: undefined } },
[["indexing", "qdrant", "apiKey"]],
[["indexing", "searchMinScore"]],
+ global.id,
+ project.id,
)
expect(conn.patches()).toEqual([
expect.objectContaining({
scope: "global",
+ expected: { path: "/config/kilo.jsonc", revision: "global-revision" },
set: { indexing: { qdrant: { apiKey: undefined } } },
unset: [["indexing", "qdrant", "apiKey"]],
}),
expect.objectContaining({
scope: "project",
+ expected: { path: "/repo/.kilo/kilo.jsonc", revision: "project-revision" },
set: { indexing: { searchMinScore: undefined } },
unset: [["indexing", "searchMinScore"]],
}),
])
})
- it("fetchAndSendIndexingStatus uses current session directory header", async () => {
+ it("reports a completed global scope when the project write conflicts", async () => {
+ const target = (scope: "global" | "project", revision: string) => ({
+ scope,
+ path: scope === "global" ? "/config/kilo.jsonc" : "/repo/.kilo/kilo.jsonc",
+ revision,
+ exists: true,
+ writable: true,
+ raw: {},
+ })
+ const snapshot = {
+ effective: { model: "test/global" },
+ targets: { global: target("global", "global-next"), project: target("project", "project-revision") },
+ }
+ const client = {
+ config: {
+ overlayUpdate: async (input: { scope: string }) => {
+ if (input.scope === "project") throw new Error("revision conflict")
+ return { data: snapshot }
+ },
+ },
+ }
+ const provider = new KiloProvider(
+ {} as never,
+ { drainPendingPrompts: async () => {}, getClient: () => client } as never,
+ )
+ const internal = provider as unknown as Internals
+ const messages: Array> = []
+ provider.postMessage = (message) => messages.push(message as Record)
+ internal.connectionState = "connected"
+ const global = binding(internal, "global")
+ const project = binding(internal, "project")
+
+ await internal.handleUpdateConfig(
+ { model: "test/global" },
+ { model: "test/project" },
+ [],
+ [],
+ global.id,
+ project.id,
+ )
+
+ expect(messages.find((message) => message.type === "configUpdateFailed")).toMatchObject({
+ completedScopes: ["global"],
+ config: snapshot.effective,
+ bindings: { global: { target: snapshot.targets.global }, project: { target: snapshot.targets.project } },
+ })
+ })
+
+ it("fetchAndSendIndexingStatus writes project consent through the dedicated endpoint", async () => {
const worktree = "/repo/.kilo/.kilocode/worktrees/feature"
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
const original = globalThis.fetch
@@ -180,6 +276,12 @@ describe("KiloProvider indexing refresh", () => {
getClient: () => ({}) as never,
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:9999", password: "secret" }),
} as never,
+ {
+ globalState: {
+ get: () => undefined,
+ update: async () => {},
+ },
+ } as never,
)
const internal = provider as unknown as Internals
provider.setSessionDirectory("ses_worktree", worktree)
@@ -192,6 +294,9 @@ describe("KiloProvider indexing refresh", () => {
const auth = Buffer.from("kilo:secret").toString("base64")
expect(headers.get("Authorization")).toBe(`Basic ${auth}`)
expect(headers.get("x-kilo-directory")).toBe(worktree)
+ expect(calls[0]?.init?.method).toBe("PUT")
+ expect(String(calls[0]?.input)).toBe("http://127.0.0.1:9999/indexing/consent")
+ expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ enabled: false })
} finally {
globalThis.fetch = original
}
diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts
new file mode 100644
index 0000000000..3144e7fde3
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts
@@ -0,0 +1,327 @@
+import { describe, it, expect } from "bun:test"
+import { ProjectRouteService } from "../../src/agent-manager/project-route"
+
+// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
+const { KiloProvider } = await import("../../src/KiloProvider")
+
+type SessionGetParams = { sessionID: string; directory: string }
+
+/**
+ * Minimal connection service mock: exposes a controllable client whose
+ * session.get records every call so tests can assert which directory was
+ * queried. Mirrors the shape used by kilo-provider-session-refresh.test.ts.
+ */
+function mockConnection(getImpl?: (p: SessionGetParams) => Promise) {
+ const calls: SessionGetParams[] = []
+ const client = {
+ session: {
+ get: async (p: SessionGetParams) => {
+ calls.push(p)
+ if (getImpl) return getImpl(p)
+ return {
+ data: {
+ id: p.sessionID,
+ slug: p.sessionID,
+ projectID: "prj-test",
+ directory: p.directory,
+ title: "s",
+ version: "1",
+ time: { created: 1, updated: 1 },
+ },
+ }
+ },
+ list: async () => ({ data: [] }),
+ },
+ provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) },
+ app: {
+ agents: async () => ({ data: [] }),
+ skills: async () => ({ data: [] }),
+ },
+ config: { get: async () => ({ data: {} }) },
+ indexing: { status: async () => ({ data: { state: "disabled" } }) },
+ kilo: {
+ notifications: async () => ({ data: [] }),
+ profile: async () => ({ data: {} }),
+ },
+ }
+ let current: typeof client | null = client
+ return {
+ calls,
+ connection: {
+ connect: async () => {
+ current = client
+ },
+ getClient: () => {
+ if (!current) throw new Error("Not connected")
+ return current
+ },
+ getClientAsync: async () => client,
+ onEventFiltered: () => () => undefined,
+ onStateChange: () => () => undefined,
+ onNotificationDismissed: () => () => undefined,
+ onLanguageChanged: () => () => undefined,
+ onProfileChanged: () => () => undefined,
+ onMigrationComplete: () => () => undefined,
+ onFavoritesChanged: () => () => undefined,
+ onModelSelectorExpandedChanged: () => () => undefined,
+ onClearPendingPrompts: () => () => undefined,
+ registerDirectoryProvider: () => () => undefined,
+ getServerInfo: () => ({ port: 12345 }),
+ getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }),
+ getConnectionState: () => "connected" as const,
+ getConnectionError: () => null,
+ resolveEventSessionId: () => undefined,
+ recordMessageSessionId: () => undefined,
+ notifyNotificationDismissed: () => undefined,
+ } as unknown as ConstructorParameters[1],
+ }
+}
+
+type ProviderInternals = {
+ client: unknown
+ connectionState: "connecting" | "connected" | "disconnected" | "error"
+ initConnectionPromise: Promise | null
+ webview: { postMessage: (message: unknown) => Promise } | null
+ handleSendCommand: (
+ command: string,
+ args: string,
+ messageID?: string,
+ sessionID?: string,
+ draftID?: string,
+ providerID?: string,
+ modelID?: string,
+ agent?: string,
+ variant?: string,
+ files?: unknown[],
+ context?: string,
+ contextDirectory?: string,
+ ) => Promise
+}
+
+/**
+ * Short-circuit initializeConnection: setting initConnectionPromise to a
+ * resolved promise makes the guarded initializeConnection() return
+ * immediately, and connectionState="connected" satisfies the guards in
+ * getSessionInfo. This avoids the full backend init sequence (commands,
+ * indexing, git status) which the minimal mock does not implement.
+ */
+function connect(internal: ProviderInternals): void {
+ internal.connectionState = "connected"
+ internal.initConnectionPromise = Promise.resolve()
+}
+
+describe("KiloProvider route integration", () => {
+ it("resolves a unique Local session route to its exact project root", async () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerSession({ projectId: "a", sessionId: "ses-local" }, "/repo/a", 1)
+ const { connection, calls } = mockConnection()
+ const provider = new KiloProvider({} as never, connection, undefined, {
+ routeService: routes,
+ rootDirectory: () => "/active/root",
+ })
+ const internal = provider as unknown as ProviderInternals
+ // Pretend the backend is already connected so getSessionInfo does not
+ // run the full initialization sequence.
+ connect(internal)
+
+ await provider.getSessionInfo("ses-local")
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.directory).toBe("/repo/a")
+ expect(calls[0]!.sessionID).toBe("ses-local")
+ })
+
+ it("resolves a unique worktree session route to its exact worktree directory", async () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerWorktree({ projectId: "a", worktreeId: "wt" }, "/repo/a/.kilo/wt", 1)
+ routes.registerSession({ projectId: "a", sessionId: "ses-wt" }, "/repo/a/.kilo/wt", 1)
+ const { connection, calls } = mockConnection()
+ const provider = new KiloProvider({} as never, connection, undefined, {
+ routeService: routes,
+ rootDirectory: () => "/active/root",
+ })
+ const internal = provider as unknown as ProviderInternals
+ connect(internal)
+
+ await provider.getSessionInfo("ses-wt")
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.directory).toBe("/repo/a/.kilo/wt")
+ })
+
+ it("does NOT query the active root for an ambiguous raw session id", async () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerProject("b", "/repo/b", 1)
+ // Same raw session id registered in two projects — ambiguous.
+ routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1)
+ routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1)
+ const { connection, calls } = mockConnection()
+ const provider = new KiloProvider({} as never, connection, undefined, {
+ routeService: routes,
+ rootDirectory: () => "/active/root",
+ })
+ const internal = provider as unknown as ProviderInternals
+ connect(internal)
+
+ const result = await provider.getSessionInfo("same")
+
+ // Must not hit the backend at all — no active-root fallback.
+ expect(calls).toHaveLength(0)
+ expect(result).toBeUndefined()
+ })
+
+ it("does NOT silently use a stale sessionDirectories entry for an ambiguous id", async () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerProject("b", "/repo/b", 1)
+ routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1)
+ routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1)
+ const { connection, calls } = mockConnection()
+ const provider = new KiloProvider({} as never, connection, undefined, {
+ routeService: routes,
+ rootDirectory: () => "/active/root",
+ })
+ // Simulate the legacy single-entry map holding only one project's dir.
+ provider.setSessionDirectory("same", "/repo/a")
+ const internal = provider as unknown as ProviderInternals
+ connect(internal)
+
+ await provider.getSessionInfo("same")
+
+ // The route service blocks the query despite sessionDirectories having
+ // an entry, because the id is ambiguous and no qualifier disambiguates.
+ expect(calls).toHaveLength(0)
+ })
+
+ it("resolves an ambiguous id through a project qualifier to the exact dir", async () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerProject("b", "/repo/b", 1)
+ routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1)
+ routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1)
+ const { connection, calls } = mockConnection()
+ const provider = new KiloProvider({} as never, connection, undefined, {
+ routeService: routes,
+ rootDirectory: () => "/active/root",
+ projectQualifier: () => ({ projectId: "b" }),
+ })
+ const internal = provider as unknown as ProviderInternals
+ connect(internal)
+
+ await provider.getSessionInfo("same")
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.directory).toBe("/repo/b")
+ })
+
+ it("falls back to sessionDirectories when no route service is configured (non-Agent-Manager)", async () => {
+ const { connection, calls } = mockConnection()
+ const provider = new KiloProvider({} as never, connection, undefined, {
+ rootDirectory: () => "/active/root",
+ })
+ provider.setSessionDirectory("ses-plain", "/some/dir")
+ const internal = provider as unknown as ProviderInternals
+ connect(internal)
+
+ await provider.getSessionInfo("ses-plain")
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.directory).toBe("/some/dir")
+ })
+
+ it("exposes route registration helpers that forward to the route service", () => {
+ const routes = new ProjectRouteService()
+ const { connection } = mockConnection()
+ const provider = new KiloProvider({} as never, connection, undefined, {
+ routeService: routes,
+ rootDirectory: () => "/active/root",
+ })
+
+ provider.registerProjectRoute({ projectId: "a" }, "/repo/a", 1)
+ provider.registerWorktreeRoute({ projectId: "a", worktreeId: "wt" }, "/repo/a/.kilo/wt", 1)
+ provider.registerSessionRoute({ projectId: "a", sessionId: "s" }, "/repo/a/.kilo/wt", 1)
+
+ expect(routes.sessionDirectory({ projectId: "a", sessionId: "s" })).toBe("/repo/a/.kilo/wt")
+ expect(routes.worktreeDirectory({ projectId: "a", worktreeId: "wt" })).toBe("/repo/a/.kilo/wt")
+ expect(provider.isSessionRouteAmbiguous("s")).toBe(false)
+ expect(provider.routeSessionDirectoryFor({ projectId: "a", sessionId: "s" })).toBe("/repo/a/.kilo/wt")
+
+ provider.unregisterSessionRoute({ projectId: "a", sessionId: "s" })
+ expect(provider.routeSessionDirectoryFor({ projectId: "a", sessionId: "s" })).toBeUndefined()
+ })
+
+ it("refuses to run a share command on an ambiguous raw session id (no active-root fallback)", async () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerProject("b", "/repo/b", 1)
+ routes.registerSession({ projectId: "a", sessionId: "same" }, "/repo/a", 1)
+ routes.registerSession({ projectId: "b", sessionId: "same" }, "/repo/b", 1)
+ const { connection, calls } = mockConnection()
+ const provider = new KiloProvider({} as never, connection, undefined, {
+ routeService: routes,
+ rootDirectory: () => "/active/root",
+ })
+ const internal = provider as unknown as ProviderInternals
+ connect(internal)
+ const posted: unknown[] = []
+ internal.webview = { postMessage: async (m) => posted.push(m) }
+
+ // share/unshare are commands routed through handleSendCommand → resolveSession.
+ await internal.handleSendCommand("share", "", "mid", "same")
+
+ // No backend session.command call should fire — the ambiguous id is
+ // refused before any directory is resolved.
+ expect(calls).toHaveLength(0)
+ const failed = posted.find(
+ (m) => typeof m === "object" && m !== null && (m as { type?: string }).type === "sendMessageFailed",
+ )
+ expect(failed, "expected a sendMessageFailed message for the ambiguous share command").toBeTruthy()
+ })
+
+ it("runs a share command on a unique session route against its exact directory", async () => {
+ const routes = new ProjectRouteService()
+ routes.registerProject("a", "/repo/a", 1)
+ routes.registerWorktree({ projectId: "a", worktreeId: "wt" }, "/repo/a/.kilo/wt", 1)
+ routes.registerSession({ projectId: "a", sessionId: "ses-wt" }, "/repo/a/.kilo/wt", 1)
+ const { connection, calls } = mockConnection()
+ // Extend the mock client with session.command to record the directory.
+ const commandCalls: { sessionID: string; directory: string; command: string }[] = []
+ ;(connection.getClient() as { session: { command: unknown } }).session.command = async (p: {
+ sessionID: string
+ directory: string
+ command: string
+ }) => {
+ commandCalls.push(p)
+ return {
+ data: {
+ id: p.sessionID,
+ slug: p.sessionID,
+ directory: p.directory,
+ title: "s",
+ version: "1",
+ time: { created: 1, updated: 1 },
+ },
+ }
+ }
+ const provider = new KiloProvider({} as never, connection, undefined, {
+ routeService: routes,
+ rootDirectory: () => "/active/root",
+ })
+ const internal = provider as unknown as ProviderInternals
+ connect(internal)
+ internal.webview = { postMessage: async () => undefined }
+
+ await internal.handleSendCommand("share", "", "mid", "ses-wt")
+
+ // session.get is called by refreshSessionDetails; the command itself
+ // must target the exact worktree directory, never /active/root.
+ expect(commandCalls).toHaveLength(1)
+ expect(commandCalls[0]!.directory).toBe("/repo/a/.kilo/wt")
+ expect(commandCalls[0]!.command).toBe("share")
+ // No fallback to the active root anywhere.
+ expect(calls.every((c) => c.directory !== "/active/root")).toBe(true)
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/navigate.test.ts b/packages/kilo-vscode/tests/unit/navigate.test.ts
index 5e8ae6f423..be6e5d816e 100644
--- a/packages/kilo-vscode/tests/unit/navigate.test.ts
+++ b/packages/kilo-vscode/tests/unit/navigate.test.ts
@@ -1,4 +1,5 @@
-import { describe, it, expect } from "bun:test"
+import { describe, it, expect, beforeEach, afterEach } from "bun:test"
+import { createRoot } from "solid-js"
import {
resolveNavigation,
validateLocalSession,
@@ -6,8 +7,21 @@ import {
canOpenRootSession,
filterUnassignedSessions,
remoteSessions,
+ buildProjectNavOrder,
+ resolveProjectNav,
+ localNavId,
+ worktreeNavId,
+ sessionNavId,
+ type ProjectNavInput,
LOCAL,
} from "../../webview-ui/agent-manager/navigate"
+import { createProjectNav, type NavTarget } from "../../webview-ui/agent-manager/project-nav"
+import type { SidebarItem } from "../../webview-ui/agent-manager/section-helpers"
+import type {
+ AgentManagerStateMessage,
+ AgentProjectSnapshot,
+ ProjectSessionInfo,
+} from "../../webview-ui/src/types/messages"
const ids = ["a", "b", "c", "d"]
@@ -322,3 +336,391 @@ describe("remoteSessions", () => {
expect(remoteSessions([], [], pending)).toEqual([])
})
})
+
+describe("buildProjectNavOrder", () => {
+ const project = (
+ p: Omit & { sessionsCollapsed?: boolean },
+ ): ProjectNavInput => ({
+ ...p,
+ sessionsCollapsed: p.sessionsCollapsed ?? false,
+ })
+
+ it("builds A Local -> A worktree -> B Local -> B worktree -> B session across expanded projects", () => {
+ const order = buildProjectNavOrder([
+ project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }),
+ project({ id: "B", expanded: true, worktrees: [{ id: "bw1" }], sections: [], unassigned: [{ id: "bs1" }] }),
+ ])
+
+ expect(order.map((e) => e.id)).toEqual([
+ localNavId("A"),
+ worktreeNavId("A", "aw1"),
+ localNavId("B"),
+ worktreeNavId("B", "bw1"),
+ sessionNavId("B", "bs1"),
+ ])
+ expect(order.map((e) => e.target)).toEqual([
+ { projectId: "A", kind: "local" },
+ { projectId: "A", kind: "worktree", worktreeId: "aw1" },
+ { projectId: "B", kind: "local" },
+ { projectId: "B", kind: "worktree", worktreeId: "bw1" },
+ { projectId: "B", kind: "session", sessionId: "bs1" },
+ ])
+ })
+
+ it("uses project-qualified composite ids, never raw worktree/session ids", () => {
+ const order = buildProjectNavOrder([
+ project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [{ id: "as1" }] }),
+ ])
+ const ids = order.map((e) => e.id)
+ expect(ids).not.toContain("aw1")
+ expect(ids).not.toContain("as1")
+ expect(ids).toContain("A:local")
+ expect(ids).toContain("A:wt:aw1")
+ expect(ids).toContain("A:sess:as1")
+ })
+
+ it("excludes collapsed projects entirely", () => {
+ const order = buildProjectNavOrder([
+ project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }),
+ project({ id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [], unassigned: [{ id: "cs1" }] }),
+ ])
+ expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1")])
+ expect(order.some((e) => e.target.kind === "worktree" && e.target.worktreeId === "cw1")).toBe(false)
+ })
+
+ it("excludes worktrees inside collapsed sections but keeps ungrouped ones", () => {
+ const order = buildProjectNavOrder([
+ project({
+ id: "A",
+ expanded: true,
+ worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }],
+ sections: [{ id: "s1", collapsed: true }],
+ unassigned: [],
+ }),
+ ])
+ expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw2")])
+ })
+
+ it("renders section members before ungrouped worktrees (matching the project body)", () => {
+ const order = buildProjectNavOrder([
+ project({
+ id: "A",
+ expanded: true,
+ worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }],
+ sections: [{ id: "s1", collapsed: false }],
+ unassigned: [],
+ }),
+ ])
+ expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1"), worktreeNavId("A", "aw2")])
+ })
+
+ it("excludes unassigned sessions when the sessions section is collapsed", () => {
+ const order = buildProjectNavOrder([
+ project({
+ id: "A",
+ expanded: true,
+ worktrees: [{ id: "aw1" }],
+ sections: [],
+ sessionsCollapsed: true,
+ unassigned: [{ id: "as1" }, { id: "as2" }],
+ }),
+ ])
+ expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1")])
+ })
+
+ it("returns an empty order when every project is collapsed", () => {
+ const order = buildProjectNavOrder([
+ project({ id: "A", expanded: false, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }),
+ ])
+ expect(order).toEqual([])
+ })
+})
+
+describe("resolveProjectNav", () => {
+ // A Local -> A worktree -> B Local -> B worktree -> B session
+ const inputs: ProjectNavInput[] = [
+ { id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], sessionsCollapsed: false, unassigned: [] },
+ {
+ id: "B",
+ expanded: true,
+ worktrees: [{ id: "bw1" }],
+ sections: [],
+ sessionsCollapsed: false,
+ unassigned: [{ id: "bs1" }],
+ },
+ ]
+ const order = buildProjectNavOrder(inputs)
+ // Collapsed project C must not appear in the order
+ const withCollapsed = buildProjectNavOrder([
+ ...inputs,
+ { id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [], sessionsCollapsed: false, unassigned: [] },
+ ])
+
+ it("walks forward A Local -> A worktree -> B Local -> B worktree -> B session", () => {
+ let current: string | undefined = undefined
+ const trail: string[] = []
+ for (let i = 0; i < 6; i++) {
+ const entry = resolveProjectNav("down", current, order)
+ if (!entry) break
+ current = entry.id
+ trail.push(entry.id)
+ }
+ expect(trail).toEqual([
+ localNavId("A"),
+ worktreeNavId("A", "aw1"),
+ localNavId("B"),
+ worktreeNavId("B", "bw1"),
+ sessionNavId("B", "bs1"),
+ ])
+ })
+
+ it("walks in reverse B session -> B worktree -> B Local -> A worktree -> A Local", () => {
+ let current: string | undefined = sessionNavId("B", "bs1")
+ const trail: string[] = [current]
+ for (let i = 0; i < 6; i++) {
+ const entry = resolveProjectNav("up", current, order)
+ if (!entry) break
+ current = entry.id
+ trail.push(current)
+ }
+ expect(trail).toEqual([
+ sessionNavId("B", "bs1"),
+ worktreeNavId("B", "bw1"),
+ localNavId("B"),
+ worktreeNavId("A", "aw1"),
+ localNavId("A"),
+ ])
+ })
+
+ it("returns undefined at the top boundary (up from first)", () => {
+ expect(resolveProjectNav("up", localNavId("A"), order)).toBeUndefined()
+ })
+
+ it("returns undefined at the bottom boundary (down from last)", () => {
+ expect(resolveProjectNav("down", sessionNavId("B", "bs1"), order)).toBeUndefined()
+ })
+
+ it("does not wrap around", () => {
+ expect(resolveProjectNav("up", localNavId("A"), order)).toBeUndefined()
+ expect(resolveProjectNav("down", sessionNavId("B", "bs1"), order)).toBeUndefined()
+ })
+
+ it("treats an unknown current as before-first (down -> first, up -> undefined)", () => {
+ expect(resolveProjectNav("down", "unknown", order)?.id).toBe(localNavId("A"))
+ expect(resolveProjectNav("up", "unknown", order)).toBeUndefined()
+ })
+
+ it("treats undefined current as before-first", () => {
+ expect(resolveProjectNav("down", undefined, order)?.id).toBe(localNavId("A"))
+ expect(resolveProjectNav("up", undefined, order)).toBeUndefined()
+ })
+
+ it("returns undefined for an empty order", () => {
+ expect(resolveProjectNav("down", localNavId("A"), [])).toBeUndefined()
+ expect(resolveProjectNav("up", undefined, [])).toBeUndefined()
+ })
+
+ it("collapsed project C is excluded from the order", () => {
+ expect(withCollapsed.length).toBe(order.length)
+ expect(withCollapsed.some((e) => e.id === worktreeNavId("C", "cw1"))).toBe(false)
+ // Forward walk still ends at B session, never reaching C
+ let current: string | undefined = undefined
+ let last: string | undefined
+ for (let i = 0; i < 10; i++) {
+ const entry = resolveProjectNav("down", current, withCollapsed)
+ if (!entry) break
+ current = entry.id
+ last = entry.id
+ }
+ expect(last).toBe(sessionNavId("B", "bs1"))
+ })
+})
+
+describe("createProjectNav", () => {
+ // activate() schedules a DOM scroll via requestAnimationFrame; bun:test has
+ // no DOM/rAF, so stub rAF to a no-op. The atomic `post` callback fires before
+ // rAF, so targets are still observable. (solid-js resolves to its server
+ // build under bun, where createMemo is one-shot, so each helper below builds
+ // a fresh controller per call — mirroring one activation per keypress.)
+ let raf: typeof globalThis.requestAnimationFrame | undefined
+ beforeEach(() => {
+ raf = globalThis.requestAnimationFrame
+ globalThis.requestAnimationFrame = (() => 0) as never
+ })
+ afterEach(() => {
+ globalThis.requestAnimationFrame = raf
+ })
+
+ const project = (id: string, expanded: boolean): AgentProjectSnapshot =>
+ ({
+ id,
+ root: `/${id}`,
+ label: id,
+ pinned: false,
+ active: id === "A",
+ expanded,
+ initialized: true,
+ trusted: true,
+ missing: false,
+ }) as AgentProjectSnapshot
+
+ const state = (
+ worktrees: { id: string; sectionId?: string }[],
+ sessions: { id: string; worktreeId: string | null }[],
+ sections: { id: string; collapsed: boolean }[] = [],
+ sessionsCollapsed = false,
+ ): AgentManagerStateMessage =>
+ ({
+ type: "agentManager.state",
+ worktrees: worktrees as never,
+ sessions: sessions as never,
+ sections,
+ sessionsCollapsed,
+ }) as AgentManagerStateMessage
+
+ const session = (id: string): ProjectSessionInfo => ({
+ id,
+ title: id,
+ createdAt: "",
+ updatedAt: "",
+ worktreeId: null,
+ })
+
+ // A (expanded): worktree aw1. B (expanded): worktree bw1 + unassigned bs1.
+ // C (collapsed): worktree cw1 — must never be reached.
+ const projects = () => [project("A", true), project("B", true), project("C", false)]
+ const states = () => ({
+ A: state([{ id: "aw1" }], []),
+ B: state([{ id: "bw1" }], [{ id: "bs1", worktreeId: null }]),
+ C: state([{ id: "cw1" }], []),
+ })
+ const sessions = () => ({ A: [], B: [session("bs1")], C: [] })
+
+ const run = (
+ fn: (nav: ReturnType) => void,
+ selection: typeof LOCAL | string | null,
+ activeProjectId: string | undefined,
+ currentSessionID: string | undefined,
+ post: (t: NavTarget) => void = () => {},
+ focus: (item: SidebarItem) => void = () => {},
+ multiProject = true,
+ sidebarOrder: () => SidebarItem[] = () => [],
+ ) =>
+ createRoot((dispose) => {
+ fn(
+ createProjectNav(
+ {
+ multiProject: () => multiProject,
+ sidebarOrder,
+ focus,
+ projects,
+ states,
+ sessions,
+ activeProjectId: () => activeProjectId,
+ selection: () => selection,
+ currentSessionID: () => currentSessionID,
+ },
+ post,
+ () => {},
+ ),
+ )
+ dispose()
+ })
+
+ const stepOnce = (
+ direction: "up" | "down",
+ selection: typeof LOCAL | string | null,
+ activeProjectId: string | undefined,
+ currentSessionID: string | undefined,
+ ): NavTarget | undefined => {
+ let posted: NavTarget | undefined
+ run(
+ (nav) => nav.step(direction),
+ selection,
+ activeProjectId,
+ currentSessionID,
+ (t) => (posted = t),
+ )
+ return posted
+ }
+
+ const jumpOnce = (index: number): NavTarget | undefined => {
+ let posted: NavTarget | undefined
+ run(
+ (nav) => nav.jump(index),
+ LOCAL,
+ "A",
+ undefined,
+ (t) => (posted = t),
+ )
+ return posted
+ }
+
+ it("multi-project step traverses A Local -> A worktree -> B Local -> B worktree -> B session", () => {
+ expect(stepOnce("down", LOCAL, "A", undefined)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" })
+ expect(stepOnce("down", "aw1", "A", undefined)).toEqual({ projectId: "B", kind: "local" })
+ expect(stepOnce("down", LOCAL, "B", undefined)).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" })
+ expect(stepOnce("down", "bw1", "B", undefined)).toEqual({ projectId: "B", kind: "session", sessionId: "bs1" })
+ expect(stepOnce("down", null, "B", "bs1")).toBeUndefined()
+ })
+
+ it("multi-project step reverses B session -> B worktree -> B Local -> A worktree -> A Local", () => {
+ expect(stepOnce("up", null, "B", "bs1")).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" })
+ expect(stepOnce("up", "bw1", "B", undefined)).toEqual({ projectId: "B", kind: "local" })
+ expect(stepOnce("up", LOCAL, "B", undefined)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" })
+ expect(stepOnce("up", "aw1", "A", undefined)).toEqual({ projectId: "A", kind: "local" })
+ expect(stepOnce("up", LOCAL, "A", undefined)).toBeUndefined()
+ })
+
+ it("collapsed project C is never reached via jump", () => {
+ // Global order: A:local(0), A:aw1(1), B:local(2), B:bw1(3), B:bs1(4).
+ expect(jumpOnce(0)).toEqual({ projectId: "A", kind: "local" })
+ expect(jumpOnce(1)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" })
+ expect(jumpOnce(2)).toEqual({ projectId: "B", kind: "local" })
+ expect(jumpOnce(3)).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" })
+ expect(jumpOnce(4)).toEqual({ projectId: "B", kind: "session", sessionId: "bs1" })
+ // Past the end — and C's collapsed worktree is never reachable by any index.
+ expect(jumpOnce(5)).toBeUndefined()
+ expect(jumpOnce(99)).toBeUndefined()
+ })
+
+ it("multi-project jump targets the global order by index (⌘3 = B Local)", () => {
+ expect(jumpOnce(2)).toEqual({ projectId: "B", kind: "local" })
+ })
+
+ it("single-project mode keeps the legacy in-process traversal and never posts activateSelection", () => {
+ const order: SidebarItem[] = [
+ { type: "local", id: "local" },
+ { type: "wt", id: "w1" },
+ { type: "session", id: "s1" },
+ ]
+ const focused: SidebarItem[] = []
+ const posted: NavTarget[] = []
+ const focus = (item: SidebarItem) => focused.push(item)
+ const post = (t: NavTarget) => posted.push(t)
+ run(
+ (nav) => nav.step("down"),
+ LOCAL,
+ "A",
+ undefined,
+ post,
+ focus,
+ false,
+ () => order,
+ )
+ expect(focused.pop()).toEqual({ type: "wt", id: "w1" })
+ run(
+ (nav) => nav.jump(2),
+ "w1",
+ "A",
+ undefined,
+ post,
+ focus,
+ false,
+ () => order,
+ )
+ expect(focused.pop()).toEqual({ type: "session", id: "s1" })
+ // Legacy path never dispatches the multi-project activation message.
+ expect(posted).toEqual([])
+ })
+})
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
index 92da93f772..f5741d2bf3 100644
--- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
+++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
@@ -36,6 +36,8 @@ import type {
RunStatus,
PRStatus,
AgentManagerPRStatusMessage,
+ AgentManagerProjectsMessage,
+ AgentProjectSnapshot,
ManagedSessionState,
SectionState,
SessionInfo,
@@ -43,19 +45,12 @@ import type {
BranchInfo,
} from "../src/types/messages"
import { IndexingProvider } from "../src/context/indexing"
-import {
- DragDropProvider,
- DragDropSensors,
- DragOverlay,
- SortableProvider,
- closestCenter,
- createSortable,
-} from "@thisbeyond/solid-dnd"
+import {} from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog"
import { Dialog } from "@kilocode/kilo-ui/dialog"
-import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
+import {} from "@kilocode/kilo-ui/dropdown-menu"
import { MarkedProvider } from "@kilocode/kilo-ui/context/marked"
import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code"
import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff"
@@ -69,7 +64,7 @@ import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
-import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
+import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { Popover } from "@kilocode/kilo-ui/popover"
import { VSCodeProvider, useVSCode } from "../src/context/vscode"
import { ServerProvider } from "../src/context/server"
@@ -88,6 +83,15 @@ import { ChatView } from "../src/components/chat"
import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm"
import HistoryView from "../src/components/history/HistoryView"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
+import { ProjectList } from "./ProjectList"
+import { SidebarBody } from "./SidebarBody"
+import { TabBar } from "./TabBar"
+import { createProjectLive } from "./project-live"
+import { createProjectSessionsLive } from "./project-sessions-live"
+import { applyProjectSelection } from "./project-selection"
+import { createLocalSessions, createLocalTabs, persistLocalTabs, type PersistedLocalTabs } from "./local-tabs-store"
+import { rememberTarget, restoreProjectTarget } from "./project-restore"
+import { selectLocalAction, selectWorktreeAction } from "./selection-actions"
import { DataBridge, MermaidDownloadBridge } from "../src/App"
import { LanguageBridge } from "../src/context/language-bridge"
import { useLanguage } from "../src/context/language"
@@ -101,6 +105,7 @@ import {
focusChatSearch,
LOCAL,
} from "./navigate"
+import { createProjectNav } from "./project-nav"
import {
addPendingTab as addLocalPendingTab,
nextTabAfterClose,
@@ -119,7 +124,7 @@ import {
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
import { createTabOrderSync } from "./tab-order-sync"
import { reportRemoteSessions, reportVisibleSession, visible } from "./remote-sessions"
-import { ConstrainDragYAxis } from "../src/components/chat/TabDnd"
+import {} from "../src/components/chat/TabDnd"
import { isTerminalTabId, createTerminalState, createTerminalHandlers, createTerminalMessageHandler } from "./terminal"
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
import { useTabScroll } from "./tab-scroll"
@@ -132,11 +137,7 @@ import type { ReviewComment } from "../diff-viewer/review-comments"
import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations"
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search"
-import { WorktreeSectionActions } from "./WorktreeSectionActions"
import { BranchSelect } from "../src/components/shared/BranchSelect"
-import { WorktreeItem } from "./WorktreeItem"
-import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
-import SectionHeader from "./SectionHeader"
import { randomColor } from "./section-colors"
import { createNewTaskDrafts } from "./new-task-drafts"
import {
@@ -148,8 +149,8 @@ import {
isGroupEnd,
type TopLevelItem,
} from "./section-helpers"
-import { sectionAwareDetector } from "./section-dnd"
-import { ConstrainDragXAxis } from "./constrain-drag-x"
+import {} from "./section-dnd"
+import {} from "./constrain-drag-x"
import { mergeWorktreeDiffs } from "../diff-viewer/diff-state"
import { initialMessage, seedInitialVariant } from "./initial-message"
import { createMarkdownRender } from "./review-preferences"
@@ -240,6 +241,13 @@ const AgentManagerContent: Component = () => {
const [isGitRepo, setIsGitRepo] = createSignal(true)
const [repoDetectedBranch, setRepoDetectedBranch] = createSignal()
const [defaultBaseBranch, setDefaultBaseBranch] = createSignal()
+ const [projectList, setProjectList] = createSignal([])
+ const [multiProject, setMultiProject] = createSignal(false)
+ const [currentProjectId, setCurrentProjectId] = createSignal()
+ const [projectStates, setProjectStates] = createSignal>({})
+ const activeProjectId = () => projectList().find((p) => p.active)?.id ?? currentProjectId()
+ const isActivePayload = (pid: string | undefined) =>
+ projectList().length === 0 || pid === undefined || pid === activeProjectId()
const repoDefaultBranch = () => defaultBaseBranch() ?? repoDetectedBranch() ?? "main"
const hasConfiguredBranch = () => !!defaultBaseBranch()
@@ -249,8 +257,12 @@ const AgentManagerContent: Component = () => {
const MAX_SIDEBAR_WIDTH_RATIO = 0.4
// Recover persisted local session IDs from webview state
- const persisted = vscode.getState<{ localSessionIDs?: string[]; sidebarWidth?: number }>()
- const [localSessionIDs, setLocalSessionIDs] = createSignal(persisted?.localSessionIDs ?? [])
+ const persisted = vscode.getState()
+ /** Bucket key for the project whose state is currently applied. */
+ const tabKey = () => currentProjectId() ?? projectList().find((p) => p.active)?.id ?? "single"
+ const tabs = createLocalTabs(persisted, tabKey)
+ const localSessionIDs = tabs.ids
+ const setLocalSessionIDs = tabs.set
/** Remove a session ID from the local tab (no-op if absent). */
const evictLocal = (sid: string) =>
setLocalSessionIDs((prev) => (prev.includes(sid) ? prev.filter((id) => id !== sid) : prev))
@@ -299,6 +311,18 @@ const AgentManagerContent: Component = () => {
// Local repo git stats (branch name, diff additions/deletions, commits)
const [localStats, setLocalStats] = createSignal()
+ const projectLive = createProjectLive(
+ {
+ stats: setWorktreeStats,
+ local: (stats) => {
+ setLocalStats(stats)
+ setRepoBranch(stats.branch)
+ },
+ pr: (worktreeId, pr) => setPrStatuses((prev) => ({ ...prev, [worktreeId]: pr })),
+ },
+ isActivePayload,
+ )
+
// Per-worktree apply-to-local status
const [applyStates, setApplyStates] = createSignal>({})
const [applyTarget, setApplyTarget] = createSignal()
@@ -309,10 +333,16 @@ const AgentManagerContent: Component = () => {
const closedDrafts = new Set()
const [activePendingId, setActivePendingId] = createSignal()
- // Per-sidebar-context terminal state. `terms.activeId` holds the id
- // of the focused terminal tab, if any — takes precedence over
- // session/pending/review when deriving the visible tab.
- const terms = createTerminalState(selection)
+ /** Per-project memory key so worktree/local ids from different projects never collide. */
+ const memKey = (sel: string) => `${activeProjectId() ?? "single"}:${sel}`
+
+ // Per-sidebar-context terminal state. `terms.activeId` holds the id of the focused
+ // terminal tab, if any — takes precedence over session/pending/review when deriving
+ // the visible tab. Contexts are project-keyed: every project reuses LOCAL and ids like "0".
+ const terms = createTerminalState(() => {
+ const sel = selection()
+ return sel === null ? null : memKey(sel)
+ })
// Inline delete confirmation: tracks which worktree is awaiting a second click/press
const [pendingDelete, setPendingDelete] = createSignal(null)
@@ -616,7 +646,7 @@ const AgentManagerContent: Component = () => {
sessions: session.sessions,
managedSessions,
reviewOpenByContext,
- terminalIdsFor: (key) => terms.forSelection(key).map((t) => t.id),
+ terminalIdsFor: (key) => terms.forSelection(memKey(key)).map((t) => t.id),
})
const appendToTabOrder = tabOrderSync.append
@@ -642,30 +672,21 @@ const AgentManagerContent: Component = () => {
if (pending && pending === active) setActivePendingId(undefined)
}
- // Persist local session IDs and sidebar width to webview state for recovery (exclude pending tabs).
- // Debounced to avoid serializing state on every pixel during resize drag.
- let persistTimer: ReturnType | undefined
- createEffect(() => {
- // Read signals eagerly so Solid tracks them as dependencies
- const ids = localSessionIDs().filter((id) => !isPending(id))
- const width = sidebarWidth()
- clearTimeout(persistTimer)
- persistTimer = setTimeout(() => {
- const prev = vscode.getState>() ?? {}
- vscode.setState({ ...prev, localSessionIDs: ids, sidebarWidth: width })
- }, 300)
+ persistLocalTabs({
+ tabs: () => tabs.durable(isPending),
+ key: tabKey,
+ width: sidebarWidth,
+ get: () => vscode.getState